diff --git a/FinlyticApp/.flutter-plugins-dependencies b/FinlyticApp/.flutter-plugins-dependencies index ee16316..f03f161 100644 --- a/FinlyticApp/.flutter-plugins-dependencies +++ b/FinlyticApp/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.3+1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_ios-6.4.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni_flutter-1.0.1\\\\","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.3.1\\\\","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.27\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_android-6.3.32\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"flutter_secure_storage_macos","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_macos-3.1.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.3+1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_macos-3.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"flutter_secure_storage_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_linux-1.2.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_linux-3.2.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"flutter_secure_storage_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_windows-3.1.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_windows-3.1.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_web-1.2.1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_web-2.4.3\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-08-15 00:06:05.344523","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_secure_storage_darwin","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_darwin-0.4.0\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.3+1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_ios-6.4.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-11.0.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni_flutter-1.0.2\\\\","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.3.1\\\\","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.27\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_android-6.3.32\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"flutter_secure_storage_darwin","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_darwin-0.4.0\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.3+1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_macos-3.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"flutter_secure_storage_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_linux-3.0.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_linux-3.2.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"flutter_secure_storage_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_windows-4.2.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_windows-3.1.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_web-2.1.1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\larsh\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_web-2.4.3\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_darwin","flutter_secure_storage_linux","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_darwin","dependencies":[]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-08-23 12:01:28.233062","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/FinlyticApp/Architecture.MD b/FinlyticApp/Architecture.MD deleted file mode 100644 index cd78bdf..0000000 --- a/FinlyticApp/Architecture.MD +++ /dev/null @@ -1,86 +0,0 @@ -# FinlyticApp — Architecture Guidelines & Engineering Standards - -Dieses Dokument definiert die verbindlichen Architektur- und Entwicklungsstandards für die **FinlyticApp** (Dart / Flutter Cross-Platform Client für Android, iOS und Web). - ---- - -## 1. Grundprinzipien & Dateistruktur - -Das Projekt folgt einer strengen **Feature-Driven Clean Architecture**. Dadurch wird eine vollständige Entkopplung von Benutzeroberfläche, Geschäftslogik und Datenquellen gewährleistet. - -### 📏 Dateigröße & Struktur-Limits -* **Maximal 150 bis 200 Zeilen pro Datei:** Wenn eine Datei diese Grenze überschreitet, muss sie in kleinere, fokussierte Einheiten refactored werden (*Single Responsibility Principle*). -* **Eine Hauptklasse pro Datei:** Helper-Klassen gehören in eigene Dateien, es sei denn, sie sind `private` und ausschließlich lokal relevant. -* **Kompakte `build()`-Methoden:** Die `build()`-Methode dient nur der Anordnung von Sub-Widgets und darf selten länger als 30–40 Zeilen sein. - -### 📁 Verzeichnisstruktur (`lib/`) - -```text -lib/ -├── core/ -│ ├── network/ # ApiClient, MQTT-Service, WebSockets -│ ├── theme/ # Finlytic Dark Glassmorphism, Typography, Colors -│ ├── utils/ # Formatierer (Währungen, Prozentangaben, Datum) -│ └── widgets/ # App-weit genutzte UI-Komponenten (Buttons, Modals) -├── features/ -│ ├── dashboard/ # Dashboard-Overview, Analytics-Cards -│ ├── news/ # Feed, Pagination, Sentiment-Analysen -│ ├── favorites/ # Favoriten-Grid, Watchlist -│ ├── calendar/ # Corporate Calendar, Earnings, Dividenden -│ ├── trades/ # Trade-Signale, Automatische Trades -│ ├── admin/ # User-Verwaltung, System-Settings (Admin-Only) -│ └── asset_detail/ # Fundamentaldaten, TA-Chart, Finance-Metrics -└── main.dart # Entry Point & Service Locator Initialization - -2. Clean Architecture Layering -Jedes Feature im features/-Ordner wird intern strikt in drei Layer unterteilt:LayerVerantwortlichkeitErlaubte Abhängigkeiten1. PresentationUI-Komponenten, Screen-Layouts, Consumer von States.Greift nur auf Logic (BLoC/Notifier) zu. Keine direkten API/DB-Calls!2. Domain / LogicBusiness-Logik, State Management, UseCases, Entities.Absolut frei von flutter/material.dart! Nutzt Repositories als Abstraktion.3. DataAPI-Clients, DTOs, Local Caching, MQTT-Stream Handlers.Implementiert Repository-Interfaces aus dem Domain Layer. - -3. Widget-Architektur & Sub-Widget Auslagerung -❌ VERBOTEN: Helper-Methoden für Widgets (_buildX())Unter keinen Umständen dürfen Methoden innerhalb von Widget-Klassen definiert werden, die ein Widget zurückgeben:Dart// ❌ FALSCH: Baut keinen eigenen BuildContext/Lifecycle auf und erfordert Rebuilding des gesamten Mutter-Widgets! -Widget _buildHeader() { - return Container( - padding: const EdgeInsets.all(16), - child: const Text('Dashboard'), - ); -} -✅ PFLICHT: Auslagerung in eigene StatelessWidget KlassenJedes logische Teilsegment der Benutzeroberfläche muss als eigene Klasse ausgegliedert werden:Dart// ✅ KORREKT: Saubere Performance, eigener BuildContext, optimierter Element-Tree -class DashboardHeader extends StatelessWidget { - const DashboardHeader({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - child: const Text('Dashboard'), - ); - } -} -⚡ Performance-Regeln für Widgets:const Konstruktoren: Jedes Sub-Widget muss wenn möglich einen const Konstruktor haben, um unnötige Re-Renders im Widget-Tree zu verhindern.Keine Business-Logik im UI-Widget: Widgets reagieren ausschließlich auf übergebene Daten oder States und leiten Nutzerinteraktionen über Callbacks / BLoC-Events weiter. - -4. Data Classes & Code-Generierung -Immutability: Alle Models, DTOs und States müssen unbeeinflussbar (immutable) sein.Freezed & JSON Serializable: Das manuelle Schreiben von fromJson, toJson oder copyWith ist untersagt. Es wird freezed zusammen mit build_runner eingesetzt.Dartimport 'package:freezed_annotation/freezed_annotation.dart'; - -part 'asset_model.freezed.dart'; -part 'asset_model.g.dart'; - -@freezed -class Asset with _$Asset { - const factory Asset({ - required String isin, - required String name, - required double currentPrice, - required String currency, - }) = _Asset; - - factory Asset.fromJson(Map 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 \ No newline at end of file diff --git a/FinlyticApp/Project.md b/FinlyticApp/Project.md deleted file mode 100644 index a192471..0000000 --- a/FinlyticApp/Project.md +++ /dev/null @@ -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. diff --git a/FinlyticApp/lib/core/network/api_client.dart b/FinlyticApp/lib/core/network/api_client.dart index ad01e00..b531e30 100644 --- a/FinlyticApp/lib/core/network/api_client.dart +++ b/FinlyticApp/lib/core/network/api_client.dart @@ -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(); } diff --git a/FinlyticApp/lib/core/network/signalr_service.dart b/FinlyticApp/lib/core/network/signalr_service.dart index 598e005..6b0f84f 100644 --- a/FinlyticApp/lib/core/network/signalr_service.dart +++ b/FinlyticApp/lib/core/network/signalr_service.dart @@ -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.broadcast(); final _healthController = StreamController>>.broadcast(); final _favoritePricesController = StreamController>.broadcast(); + final _tradeProposalController = StreamController>.broadcast(); + final _tradeUpdateController = StreamController>.broadcast(); + final _botPositionController = StreamController>.broadcast(); + final _portfolioSummaryController = StreamController>.broadcast(); + final _logMessageController = StreamController>.broadcast(); + final _newsArticleController = StreamController>.broadcast(); bool get isConnected => _isConnected; Stream get connectionStream => _statusController.stream; Stream>> get healthStream => _healthController.stream; Stream> get favoritePricesStream => _favoritePricesController.stream; + Stream> get tradeProposalStream => _tradeProposalController.stream; + Stream> get tradeUpdateStream => _tradeUpdateController.stream; + Stream> get botPositionStream => _botPositionController.stream; + Stream> get portfolioSummaryStream => _portfolioSummaryController.stream; + Stream> get logMessageStream => _logMessageController.stream; + Stream> 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 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 map = Map.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 map = Map.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 map = Map.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 map = Map.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 map = Map.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 map = Map.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> healthData) { - _healthController.add(healthData); - notifyListeners(); - } - - void broadcastFavoritePrices(Map 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(); } } diff --git a/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_bloc.dart b/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_bloc.dart new file mode 100644 index 0000000..4eab6df --- /dev/null +++ b/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_bloc.dart @@ -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 { + final AdminRepository repository; + + AdminEvaluationHistoryBloc({required this.repository}) : super(AdminEvaluationHistoryInitial()) { + on(_onFetch); + } + + Future _onFetch(FetchEvaluationHistory event, Emitter 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: ', ''))); + } + } +} diff --git a/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_event.dart b/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_event.dart new file mode 100644 index 0000000..675d365 --- /dev/null +++ b/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_event.dart @@ -0,0 +1,36 @@ +import 'package:equatable/equatable.dart'; +import '../models/evaluation_history_enums.dart'; + +abstract class AdminEvaluationHistoryEvent extends Equatable { + const AdminEvaluationHistoryEvent(); + + @override + List 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 get props => [fromUtc, toUtc, outcome, triggerSource, search, page, pageSize]; +} diff --git a/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_state.dart b/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_state.dart new file mode 100644 index 0000000..102fc6f --- /dev/null +++ b/FinlyticApp/lib/features/admin/bloc/admin_evaluation_history_state.dart @@ -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 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 get props => [response, page, pageSize]; +} + +class AdminEvaluationHistoryError extends AdminEvaluationHistoryState { + final String message; + + const AdminEvaluationHistoryError(this.message); + + @override + List get props => [message]; +} diff --git a/FinlyticApp/lib/features/admin/models/evaluation_history_entry_model.dart b/FinlyticApp/lib/features/admin/models/evaluation_history_entry_model.dart new file mode 100644 index 0000000..2664137 --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/evaluation_history_entry_model.dart @@ -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 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 get props => [ + id, + isin, + symbol, + technicalScore, + sentimentScore, + fundamentalScore, + compositeOpportunityScore, + reliabilityBonus, + passedEarningsLockout, + daysToNextEarnings, + passedDividendGate, + daysToNextExDividend, + universeSource, + universeEnteredAtUtc, + passedSimulationVeto, + passedAiValidation, + aiThesisSummary, + outcomeReason, + triggerSource, + triggeredByUserId, + proposalId, + evaluatedAtUtc, + ]; +} diff --git a/FinlyticApp/lib/features/admin/models/evaluation_history_enums.dart b/FinlyticApp/lib/features/admin/models/evaluation_history_enums.dart new file mode 100644 index 0000000..fde2994 --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/evaluation_history_enums.dart @@ -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'; + } + } +} diff --git a/FinlyticApp/lib/features/admin/models/evaluation_history_response_model.dart b/FinlyticApp/lib/features/admin/models/evaluation_history_response_model.dart new file mode 100644 index 0000000..07df031 --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/evaluation_history_response_model.dart @@ -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 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 json) { + final rawEntries = json['entries']; + final entries = rawEntries is List + ? rawEntries.whereType>().map(EvaluationHistoryEntryModel.fromJson).toList() + : []; + + final rawSummary = json['summary']; + final summary = rawSummary is Map + ? EvaluationHistorySummaryModel.fromJson(rawSummary) + : EvaluationHistorySummaryModel.empty(); + + return EvaluationHistoryResponseModel( + totalCount: json['totalCount'] is num ? (json['totalCount'] as num).toInt() : 0, + entries: entries, + summary: summary, + ); + } + + @override + List get props => [totalCount, entries, summary]; +} diff --git a/FinlyticApp/lib/features/admin/models/evaluation_history_summary_model.dart b/FinlyticApp/lib/features/admin/models/evaluation_history_summary_model.dart new file mode 100644 index 0000000..8c27991 --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/evaluation_history_summary_model.dart @@ -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 json) { + return OutcomeReasonCountModel( + outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()), + count: json['count'] is num ? (json['count'] as num).toInt() : 0, + ); + } + + @override + List 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 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 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(OutcomeReasonCountModel.fromJson).toList() + : []; + + 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 get props => [totalEvaluations, countsByOutcome, averageCompositeScore, proposalsCreated, lastProposalCreatedAtUtc]; +} diff --git a/FinlyticApp/lib/features/admin/models/recent_setup_model.dart b/FinlyticApp/lib/features/admin/models/recent_setup_model.dart new file mode 100644 index 0000000..17c811e --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/recent_setup_model.dart @@ -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 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 get props => [strategyName, qualityScore, isTopPick, rating, createdAt]; +} diff --git a/FinlyticApp/lib/features/admin/models/watchlist_entry_model.dart b/FinlyticApp/lib/features/admin/models/watchlist_entry_model.dart new file mode 100644 index 0000000..4ab353f --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/watchlist_entry_model.dart @@ -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 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 get props => [isin, symbol, source, priority, addedAtUtc, expiresAtUtc]; +} diff --git a/FinlyticApp/lib/features/admin/repositories/admin_repository.dart b/FinlyticApp/lib/features/admin/repositories/admin_repository.dart index 18b875f..d653588 100644 --- a/FinlyticApp/lib/features/admin/repositories/admin_repository.dart +++ b/FinlyticApp/lib/features/admin/repositories/admin_repository.dart @@ -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 fetchEvaluationHistory({ + DateTime? fromUtc, + DateTime? toUtc, + OutcomeReason? outcome, + TriggerSource? triggerSource, + String? search, + int page = 1, + int pageSize = 50, + }) async { + final query = { + '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) { + return EvaluationHistoryResponseModel.fromJson(res.data as Map); + } + 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> 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(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> 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(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.'); + } + } } diff --git a/FinlyticApp/lib/features/admin/views/admin_evaluation_history_screen.dart b/FinlyticApp/lib/features/admin/views/admin_evaluation_history_screen.dart new file mode 100644 index 0000000..1c8c05a --- /dev/null +++ b/FinlyticApp/lib/features/admin/views/admin_evaluation_history_screen.dart @@ -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().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( + 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'), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/admin/views/service_detail_screen.dart b/FinlyticApp/lib/features/admin/views/service_detail_screen.dart index bdd4deb..1fb3f3b 100644 --- a/FinlyticApp/lib/features/admin/views/service_detail_screen.dart +++ b/FinlyticApp/lib/features/admin/views/service_detail_screen.dart @@ -131,7 +131,11 @@ class _ServiceDetailScreenState extends State { } 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'(? { .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 _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> get _groupedSettings { + final groups = >{}; + 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 _buildGroupedSettingsSections() { + final grouped = _groupedSettings; + final widgets = []; + + 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 = []; + 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 { 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 { 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)), - ], - ), - ), ], ), ), diff --git a/FinlyticApp/lib/features/admin/widgets/evaluation_history_filter_bar.dart b/FinlyticApp/lib/features/admin/widgets/evaluation_history_filter_bar.dart new file mode 100644 index 0000000..3ac9321 --- /dev/null +++ b/FinlyticApp/lib/features/admin/widgets/evaluation_history_filter_bar.dart @@ -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 createState() => _EvaluationHistoryFilterBarState(); +} + +class _EvaluationHistoryFilterBarState extends State { + late final TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(text: widget.search); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _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( + initialValue: widget.outcome, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Ergebnis', isDense: true), + items: [ + const DropdownMenuItem(value: null, child: Text('Alle Ergebnisse')), + ...OutcomeReason.values.map( + (r) => DropdownMenuItem(value: r, child: Text(r.label)), + ), + ], + onChanged: (val) => _emit(outcome: val), + ), + ), + SizedBox( + width: 170, + child: DropdownButtonFormField( + initialValue: widget.triggerSource, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Ausgelöst durch', isDense: true), + items: [ + const DropdownMenuItem(value: null, child: Text('Alle Quellen')), + ...TriggerSource.values.map( + (t) => DropdownMenuItem(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(); diff --git a/FinlyticApp/lib/features/admin/widgets/evaluation_history_kpi_header.dart b/FinlyticApp/lib/features/admin/widgets/evaluation_history_kpi_header.dart new file mode 100644 index 0000000..ddc3216 --- /dev/null +++ b/FinlyticApp/lib/features/admin/widgets/evaluation_history_kpi_header.dart @@ -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() + .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, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/admin/widgets/evaluation_history_list_item.dart b/FinlyticApp/lib/features/admin/widgets/evaluation_history_list_item.dart new file mode 100644 index 0000000..fe6befc --- /dev/null +++ b/FinlyticApp/lib/features/admin/widgets/evaluation_history_list_item.dart @@ -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), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/admin/widgets/live_log_console.dart b/FinlyticApp/lib/features/admin/widgets/live_log_console.dart index 18fef4a..ffcbdfd 100644 --- a/FinlyticApp/lib/features/admin/widgets/live_log_console.dart +++ b/FinlyticApp/lib/features/admin/widgets/live_log_console.dart @@ -157,9 +157,33 @@ class _LiveLogConsoleState extends State { } } + /// 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 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) { diff --git a/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart b/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart index b671cb4..e9279b2 100644 --- a/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart +++ b/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart @@ -67,16 +67,9 @@ class _PipelineSettingsWidgetState extends State { 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 { 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> _controllers = { @@ -113,24 +113,35 @@ class _PipelineSettingsWidgetState extends State { '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; diff --git a/FinlyticApp/lib/features/admin/widgets/system_diagnostics_widget.dart b/FinlyticApp/lib/features/admin/widgets/system_diagnostics_widget.dart index 4030dd8..289a064 100644 --- a/FinlyticApp/lib/features/admin/widgets/system_diagnostics_widget.dart +++ b/FinlyticApp/lib/features/admin/widgets/system_diagnostics_widget.dart @@ -46,6 +46,32 @@ class _SystemDiagnosticsWidgetState extends State { 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 { Row( children: [ Icon( - name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined, + _getServiceIcon(name), size: 18, color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed, ), diff --git a/FinlyticApp/lib/features/admin/widgets/watchlist_card.dart b/FinlyticApp/lib/features/admin/widgets/watchlist_card.dart new file mode 100644 index 0000000..d3fcc10 --- /dev/null +++ b/FinlyticApp/lib/features/admin/widgets/watchlist_card.dart @@ -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 createState() => _WatchlistCardState(); +} + +class _WatchlistCardState extends State { + late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient); + List? _entries; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 initialEntries; + + const _WatchlistDialog({required this.repository, required this.initialEntries}); + + @override + State<_WatchlistDialog> createState() => _WatchlistDialogState(); +} + +class _WatchlistDialogState extends State<_WatchlistDialog> { + late List _entries = widget.initialEntries; + bool _refreshing = false; + + Future _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? _history; + bool _loading = false; + String? _error; + + Future _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, + ), + ), + ), + ], + ), + )), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart index 23cedeb..fb36121 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_bloc.dart @@ -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 { on((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.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((event, emit) async { - try { - await repository.rejectTrade(event.tradeId); - add(LoadAssetTrades(event.isin)); - } catch (e) { - emit(AssetTradesError("Failed to reject trade: $e")); + on((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((event, emit) async { @@ -52,6 +46,14 @@ class AssetTradesBloc extends Bloc { emit(AssetTradesError("Failed to accept trade: $e")); } }); + on((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((event, emit) async { try { await repository.closeTrade(event.tradeId, event.exitPrice); diff --git a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_event.dart b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_event.dart index 9b1e6df..34e31fd 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_event.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_event.dart @@ -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; diff --git a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_state.dart b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_state.dart index 95d2f11..4c94853 100644 --- a/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_state.dart +++ b/FinlyticApp/lib/features/asset_detail/bloc/trades/asset_trades_state.dart @@ -5,7 +5,23 @@ class AssetTradesInitial extends AssetTradesState {} class AssetTradesLoading extends AssetTradesState {} class AssetTradesLoaded extends AssetTradesState { final List 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; diff --git a/FinlyticApp/lib/features/asset_detail/models/manual_analysis_response_dto.dart b/FinlyticApp/lib/features/asset_detail/models/manual_analysis_response_dto.dart deleted file mode 100644 index 6566606..0000000 --- a/FinlyticApp/lib/features/asset_detail/models/manual_analysis_response_dto.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:equatable/equatable.dart'; -import '../../trades/models/trade_model.dart'; - -class ExecutionPlanModel extends Equatable { - final double stopLoss; - final List 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 json) { - double parseDbl(dynamic v) => (v as num?)?.toDouble() ?? 0.0; - return ExecutionPlanModel( - stopLoss: parseDbl(json['stopLoss']), - takeProfitTargets: (json['takeProfitTargets'] as List? ?? []).map((e) => parseDbl(e)).toList(), - riskRewardRatio: parseDbl(json['riskRewardRatio']), - maxLeverage: parseDbl(json['maxLeverage']) == 0 ? 1.0 : parseDbl(json['maxLeverage']), - ); - } - - @override - List 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 json) { - return DetailedAnalysisModel( - technicalRationale: json['technicalRationale']?.toString() ?? '', - fundamentalRationale: json['fundamentalRationale']?.toString() ?? '', - riskWarning: json['riskWarning']?.toString() ?? '', - ); - } - - @override - List 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 json) { - ExecutionPlanModel? execPlan; - if (json['executionPlan'] != null && json['executionPlan'] is Map) { - execPlan = ExecutionPlanModel.fromJson(json['executionPlan']); - } - - DetailedAnalysisModel? detailAnalysis; - if (json['detailedAnalysis'] != null && json['detailedAnalysis'] is Map) { - 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 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 json) { - N8nAnalysisResponseDto? n8n; - if (json['n8nResponse'] != null && json['n8nResponse'] is Map) { - n8n = N8nAnalysisResponseDto.fromJson(json['n8nResponse']); - } - - TradeModel? prop; - if (json['proposal'] != null && json['proposal'] is Map) { - 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 get props => [analysisId, isTradeProposed, status, recommendation, n8nResponse, proposal, message]; -} diff --git a/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart b/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart index e4823b4..812a243 100644 --- a/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart +++ b/FinlyticApp/lib/features/asset_detail/repositories/asset_repository.dart @@ -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 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) { + 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> getAssetTrades(String isin, String? status) async { return _tradeRepository.fetchTrades(isin: isin, status: status); } - Future 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 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) { - return ManualAnalysisResponseDto.fromJson(res.data); + if (res.data != null && res.data is Map) { + return AssetEvaluationResultModel.fromJson(res.data); } - return null; + throw StateError('Manual analysis endpoint returned an unexpected empty/non-object body.'); } - Future rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId); - Future acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto); Future closeTrade(String tradeId, double exitPrice) async => _tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice)); + + Future addTradeFill(String tradeId, {required double executedPrice, required double quantity}) async => + _tradeRepository.addTradeFill(tradeId, executedPrice: executedPrice, quantity: quantity); } diff --git a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart index 74b71a5..b77ee06 100644 --- a/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart +++ b/FinlyticApp/lib/features/asset_detail/views/tabs/trades_tab.dart @@ -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 { - bool _justTriggeredAnalysis = false; + late final BotRepository _botRepository; @override void initState() { super.initState(); + _botRepository = BotRepository(apiClient: context.read()); context.read().add(LoadAssetTrades(widget.symbol)); } @@ -39,8 +44,23 @@ class _TradesTabState extends State { 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 { ); }, 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 { ); } + Future _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( listener: (context, state) { - if (_justTriggeredAnalysis && state is AssetTradesLoaded) { - final List tradesList = state.data; - if (tradesList.isNotEmpty) { - _justTriggeredAnalysis = false; - final latestTrade = tradesList.first; - WidgetsBinding.instance.addPostFrameCallback((_) { - _showEditTradeExecutionDialog(context, latestTrade); - }); + if (state is! AssetTradesLoaded) return; + + final result = state.manualAnalysisResult; + if (result == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + 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 tradesList = (state is AssetTradesLoaded) ? state.data : []; @@ -117,11 +223,10 @@ class _TradesTabState extends State { symbol: widget.symbol, initialRiskScore: 50.0, onTrigger: (payload) { - setState(() => _justTriggeredAnalysis = true); context.read().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 { 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 { 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 { 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().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart index 90062bd..ae7e1c2 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/asset_trade_item_card.dart @@ -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,25 +79,26 @@ 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( - color: AppTheme.glassSurface, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - trade.derivativeIsin.isNotEmpty ? '${trade.instrumentType} (${trade.derivativeIsin})' : trade.instrumentType, - style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold), - ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(6), ), + child: Text( + trade.derivativeIsin != null && trade.derivativeIsin!.isNotEmpty + ? '${trade.instrumentType.label} (${trade.derivativeIsin})' + : trade.instrumentType.label, + style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold), + ), + ), ], ), Row( @@ -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: Row( + children: [ + Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Stückzahl: ${_fmt(trade.totalQuantity)}${trade.isDerivative ? ' (Derivat)' : ''}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white), + ), + ), + ], + ), + ), + ], + + // PnL (server-computed, never recalculated client-side) + if (showPnl) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pnlColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pnlColor), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald), + Icon(isPnlWin ? Icons.trending_up : Icons.trending_down, size: 16, color: pnlColor), const SizedBox(width: 6), - const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, 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('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), + _buildTradeStat('Aktueller Kurs', '€${_fmt(trade.currentPrice)}', Colors.white), + _buildTradeStat('PnL (€)', '${isPnlWin ? "+€" : "-€"}${_fmt(pnlEur.abs())}', pnlColor), + _buildTradeStat('PnL (%)', '${pnlPercent >= 0 ? "+" : ""}${_fmt(pnlPercent)}%', pnlColor), ], ), - 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) ...[ - 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( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color), - const SizedBox(width: 6), - const Text('Trade Ergebnis & Realisierter PnL:', style: 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), - ], - ), - 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)), - ], - ); - } } - diff --git a/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart b/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart index 9a33b31..dac8027 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/trades/close_trade_dialog.dart @@ -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, ), ), ], diff --git a/FinlyticApp/lib/features/auth/bloc/auth_bloc.dart b/FinlyticApp/lib/features/auth/bloc/auth_bloc.dart index eb007ec..f7c0629 100644 --- a/FinlyticApp/lib/features/auth/bloc/auth_bloc.dart +++ b/FinlyticApp/lib/features/auth/bloc/auth_bloc.dart @@ -19,7 +19,6 @@ class AuthBloc extends Bloc { super(AuthInitial()) { on(_onCheckAuthStatus); on(_onLoginRequested); - on(_onRegisterRequested); on(_onLogoutRequested); } @@ -44,16 +43,6 @@ class AuthBloc extends Bloc { } } - Future _onRegisterRequested(RegisterRequested event, Emitter 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 _onLogoutRequested(LogoutRequested event, Emitter emit) async { await repository.logout(); emit(Unauthenticated()); diff --git a/FinlyticApp/lib/features/auth/bloc/auth_event.dart b/FinlyticApp/lib/features/auth/bloc/auth_event.dart index 3cf792c..73ab5bc 100644 --- a/FinlyticApp/lib/features/auth/bloc/auth_event.dart +++ b/FinlyticApp/lib/features/auth/bloc/auth_event.dart @@ -19,15 +19,4 @@ class LoginRequested extends AuthEvent { List 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 get props => [email, password, fullName]; -} - class LogoutRequested extends AuthEvent {} diff --git a/FinlyticApp/lib/features/auth/repositories/auth_repository.dart b/FinlyticApp/lib/features/auth/repositories/auth_repository.dart index 7539dd5..9ecb046 100644 --- a/FinlyticApp/lib/features/auth/repositories/auth_repository.dart +++ b/FinlyticApp/lib/features/auth/repositories/auth_repository.dart @@ -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 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 changeInitialPassword(String userId, String newPassword) async { final res = await apiClient.post('/api/v1/auth/change-initial-password', data: { 'userId': userId, diff --git a/FinlyticApp/lib/features/auth/views/change_initial_password_screen.dart b/FinlyticApp/lib/features/auth/views/change_initial_password_screen.dart index 8111359..d257f00 100644 --- a/FinlyticApp/lib/features/auth/views/change_initial_password_screen.dart +++ b/FinlyticApp/lib/features/auth/views/change_initial_password_screen.dart @@ -20,6 +20,8 @@ class _ChangeInitialPasswordScreenState extends State 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), diff --git a/FinlyticApp/lib/features/auth/views/login_screen.dart b/FinlyticApp/lib/features/auth/views/login_screen.dart index 00f8959..239e83a 100644 --- a/FinlyticApp/lib/features/auth/views/login_screen.dart +++ b/FinlyticApp/lib/features/auth/views/login_screen.dart @@ -16,6 +16,7 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + bool _obscurePassword = true; @override void dispose() { @@ -75,8 +76,15 @@ class _LoginScreenState extends State { 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 { ); }, ), + const SizedBox(height: 16), + Text( + "Konten werden ausschließlich vom Administrator angelegt.", + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), + ), ], ), ), diff --git a/FinlyticApp/lib/features/auth/views/register_screen.dart b/FinlyticApp/lib/features/auth/views/register_screen.dart deleted file mode 100644 index ec6cdb6..0000000 --- a/FinlyticApp/lib/features/auth/views/register_screen.dart +++ /dev/null @@ -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 createState() => _RegisterScreenState(); -} - -class _RegisterScreenState extends State { - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - - void _submit() { - if (_formKey.currentState?.validate() ?? false) { - context.read().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)), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/FinlyticApp/lib/features/bot/bloc/bot_bloc.dart b/FinlyticApp/lib/features/bot/bloc/bot_bloc.dart new file mode 100644 index 0000000..b0de34d --- /dev/null +++ b/FinlyticApp/lib/features/bot/bloc/bot_bloc.dart @@ -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 { + final BotRepository repository; + final SignalRService? signalRService; + + StreamSubscription? _botPositionSub; + StreamSubscription? _portfolioSummarySub; + + BotBloc({ + required this.repository, + this.signalRService, + }) : super(const BotInitial()) { + on(_onFetchBotDashboard); + on(_onBotPositionStreamReceived); + on(_onPortfolioSummaryStreamReceived); + on(_onTriggerBotPanicClose); + on(_onExecuteManualBotProposal); + on(_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 _onFetchBotDashboard(FetchBotDashboard event, Emitter 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; + + emit(BotLoaded( + status: status, + summary: summary, + positions: positions, + )); + } catch (e) { + emit(BotError(e.toString())); + } + } + + void _onBotPositionStreamReceived(OnBotPositionStreamReceived event, Emitter emit) { + if (state is BotLoaded) { + final current = state as BotLoaded; + final updatedList = List.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 emit) { + if (state is BotLoaded) { + final current = state as BotLoaded; + emit(current.copyWith(summary: event.summary)); + } + } + + Future _onTriggerBotPanicClose(TriggerBotPanicClose event, Emitter 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 _onExecuteManualBotProposal(ExecuteManualBotProposal event, Emitter 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.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 _onUpdateBotConfigSettings(UpdateBotConfigSettings event, Emitter 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 close() { + _botPositionSub?.cancel(); + _portfolioSummarySub?.cancel(); + return super.close(); + } +} diff --git a/FinlyticApp/lib/features/bot/bloc/bot_event.dart b/FinlyticApp/lib/features/bot/bloc/bot_event.dart new file mode 100644 index 0000000..10d2db2 --- /dev/null +++ b/FinlyticApp/lib/features/bot/bloc/bot_event.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; +import '../models/bot_models.dart'; + +abstract class BotEvent extends Equatable { + const BotEvent(); + + @override + List get props => []; +} + +class FetchBotDashboard extends BotEvent { + const FetchBotDashboard(); +} + +class OnBotPositionStreamReceived extends BotEvent { + final BotTradeOrderModel position; + + const OnBotPositionStreamReceived(this.position); + + @override + List get props => [position]; +} + +class OnPortfolioSummaryStreamReceived extends BotEvent { + final AccountSummaryModel summary; + + const OnPortfolioSummaryStreamReceived(this.summary); + + @override + List 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 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 get props => [autoExecutionEnabled, maxPositions, riskPerTradePercent, minCompositeScore]; +} diff --git a/FinlyticApp/lib/features/bot/bloc/bot_state.dart b/FinlyticApp/lib/features/bot/bloc/bot_state.dart new file mode 100644 index 0000000..c4ecc47 --- /dev/null +++ b/FinlyticApp/lib/features/bot/bloc/bot_state.dart @@ -0,0 +1,73 @@ +import 'package:equatable/equatable.dart'; +import '../models/bot_models.dart'; + +abstract class BotState extends Equatable { + const BotState(); + + @override + List 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 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? 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 get props => [status, summary, positions, isPanicClosing, actionMessage, actionIsWarning]; +} + +class BotError extends BotState { + final String message; + + const BotError(this.message); + + @override + List get props => [message]; +} diff --git a/FinlyticApp/lib/features/bot/models/bot_models.dart b/FinlyticApp/lib/features/bot/models/bot_models.dart new file mode 100644 index 0000000..b984ecc --- /dev/null +++ b/FinlyticApp/lib/features/bot/models/bot_models.dart @@ -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 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 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 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 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 closedOrders; + + const PanicCloseResultModel({ + required this.closedCount, + required this.skippedCount, + required this.closedOrders, + }); + + factory PanicCloseResultModel.fromJson(Map json) { + final List orders = json['closedOrders'] as List? ?? 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)).toList(), + ); + } + + @override + List 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 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 get props => [ + isRunning, autoExecutionEnabled, activePositionsCount, + maxPositions, riskPerTradePercent, minCompositeScore, venuesActive + ]; +} diff --git a/FinlyticApp/lib/features/bot/repositories/bot_repository.dart b/FinlyticApp/lib/features/bot/repositories/bot_repository.dart new file mode 100644 index 0000000..ed1c937 --- /dev/null +++ b/FinlyticApp/lib/features/bot/repositories/bot_repository.dart @@ -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 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 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> fetchActivePositions() async { + final response = await apiClient.get('/api/v1/bot/positions/active'); + if (response.statusCode == 200 && response.data != null) { + final List list = response.data; + return list.map((json) => BotTradeOrderModel.fromJson(json)).toList(); + } + return []; + } + + Future 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 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 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(); + } +} diff --git a/FinlyticApp/lib/features/bot/views/bot_control_panel_screen.dart b/FinlyticApp/lib/features/bot/views/bot_control_panel_screen.dart new file mode 100644 index 0000000..0deaf01 --- /dev/null +++ b/FinlyticApp/lib/features/bot/views/bot_control_panel_screen.dart @@ -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(); + 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( + 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().add(const FetchBotDashboard()), + icon: const Icon(Icons.refresh, color: Colors.white70), + tooltip: 'Neu laden', + ), + ], + ), + body: BlocConsumer( + 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().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().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), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/bot/widgets/bot_kpi_header.dart b/FinlyticApp/lib/features/bot/widgets/bot_kpi_header.dart new file mode 100644 index 0000000..39d0fba --- /dev/null +++ b/FinlyticApp/lib/features/bot/widgets/bot_kpi_header.dart @@ -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)), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/bot/widgets/bot_position_card.dart b/FinlyticApp/lib/features/bot/widgets/bot_position_card.dart new file mode 100644 index 0000000..a7f1719 --- /dev/null +++ b/FinlyticApp/lib/features/bot/widgets/bot_position_card.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/bot/widgets/bot_settings_sheet.dart b/FinlyticApp/lib/features/bot/widgets/bot_settings_sheet.dart new file mode 100644 index 0000000..f802ac7 --- /dev/null +++ b/FinlyticApp/lib/features/bot/widgets/bot_settings_sheet.dart @@ -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 createState() => _BotSettingsSheetState(); +} + +class _BotSettingsSheetState extends State { + 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'), + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/dashboard/views/dashboard_screen.dart b/FinlyticApp/lib/features/dashboard/views/dashboard_screen.dart index 9f930d9..643da30 100644 --- a/FinlyticApp/lib/features/dashboard/views/dashboard_screen.dart +++ b/FinlyticApp/lib/features/dashboard/views/dashboard_screen.dart @@ -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), ], ), ), diff --git a/FinlyticApp/lib/features/dashboard/widgets/daily_news_snapshot.dart b/FinlyticApp/lib/features/dashboard/widgets/daily_news_snapshot.dart index 65b5bcd..064e495 100644 --- a/FinlyticApp/lib/features/dashboard/widgets/daily_news_snapshot.dart +++ b/FinlyticApp/lib/features/dashboard/widgets/daily_news_snapshot.dart @@ -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(), ); diff --git a/FinlyticApp/lib/features/dashboard/widgets/favorites_carousel.dart b/FinlyticApp/lib/features/dashboard/widgets/favorites_carousel.dart index 0cb23f2..9fc340c 100644 --- a/FinlyticApp/lib/features/dashboard/widgets/favorites_carousel.dart +++ b/FinlyticApp/lib/features/dashboard/widgets/favorites_carousel.dart @@ -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), diff --git a/FinlyticApp/lib/features/dashboard/widgets/trades_stream_widget.dart b/FinlyticApp/lib/features/dashboard/widgets/trades_stream_widget.dart index 7aef78a..7ef8b8f 100644 --- a/FinlyticApp/lib/features/dashboard/widgets/trades_stream_widget.dart +++ b/FinlyticApp/lib/features/dashboard/widgets/trades_stream_widget.dart @@ -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().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, - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 4), + Text( + 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(), diff --git a/FinlyticApp/lib/features/favorites/widgets/watchlist_card.dart b/FinlyticApp/lib/features/favorites/widgets/watchlist_card.dart index 4dcbc33..ac8e4af 100644 --- a/FinlyticApp/lib/features/favorites/widgets/watchlist_card.dart +++ b/FinlyticApp/lib/features/favorites/widgets/watchlist_card.dart @@ -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), diff --git a/FinlyticApp/lib/features/news/bloc/news_bloc.dart b/FinlyticApp/lib/features/news/bloc/news_bloc.dart index 56a1116..4ee7599 100644 --- a/FinlyticApp/lib/features/news/bloc/news_bloc.dart +++ b/FinlyticApp/lib/features/news/bloc/news_bloc.dart @@ -14,13 +14,11 @@ class NewsBloc extends Bloc { on(_onLoadMoreNews); on(_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 _onFetchNews(FetchNews event, Emitter emit) async { diff --git a/FinlyticApp/lib/features/news/repositories/news_repository.dart b/FinlyticApp/lib/features/news/repositories/news_repository.dart index 1d6dc62..8f63d8e 100644 --- a/FinlyticApp/lib/features/news/repositories/news_repository.dart +++ b/FinlyticApp/lib/features/news/repositories/news_repository.dart @@ -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.broadcast(); - Stream get liveNewsStream => _liveNewsController.stream; + /// 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; - NewsRepository({required this.apiClient, required this.backendUrl}); + NewsRepository({ + required this.apiClient, + required this.backendUrl, + this.signalRService, + }); + + /// 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 get liveNewsStream { + final signalR = signalRService; + if (signalR == null) return const Stream.empty(); + return signalR.newsArticleStream.map(NewsArticleModel.fromJson); + } Future> fetchNews({ int page = 1, @@ -31,7 +40,7 @@ class NewsRepository { 'page': page, 'pageSize': pageSize, }; - + if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol; if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin; if (date != null && date.isNotEmpty) queryParams['date'] = date; @@ -66,55 +75,9 @@ class NewsRepository { } } - Future 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 jsonData = rawPayload is String - ? jsonDecode(rawPayload) - : Map.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 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() {} } diff --git a/FinlyticApp/lib/features/news/views/news_feed_screen.dart b/FinlyticApp/lib/features/news/views/news_feed_screen.dart index bc5afe7..a336f7d 100644 --- a/FinlyticApp/lib/features/news/views/news_feed_screen.dart +++ b/FinlyticApp/lib/features/news/views/news_feed_screen.dart @@ -92,6 +92,11 @@ class _NewsFeedScreenState extends State { _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 { _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, ), diff --git a/FinlyticApp/lib/features/proposals/views/proposal_decision_screen.dart b/FinlyticApp/lib/features/proposals/views/proposal_decision_screen.dart new file mode 100644 index 0000000..1a39936 --- /dev/null +++ b/FinlyticApp/lib/features/proposals/views/proposal_decision_screen.dart @@ -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(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)), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/search/bloc/search_bloc.dart b/FinlyticApp/lib/features/search/bloc/search_bloc.dart index ccda272..40a606a 100644 --- a/FinlyticApp/lib/features/search/bloc/search_bloc.dart +++ b/FinlyticApp/lib/features/search/bloc/search_bloc.dart @@ -26,12 +26,14 @@ extension SwitchMapExtension on Stream { 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; diff --git a/FinlyticApp/lib/features/search/widgets/asset_search_dialog.dart b/FinlyticApp/lib/features/search/widgets/asset_search_dialog.dart index db2c3c9..c9d1ad5 100644 --- a/FinlyticApp/lib/features/search/widgets/asset_search_dialog.dart +++ b/FinlyticApp/lib/features/search/widgets/asset_search_dialog.dart @@ -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( diff --git a/FinlyticApp/lib/features/simulation/cubit/backtest_cubit.dart b/FinlyticApp/lib/features/simulation/cubit/backtest_cubit.dart new file mode 100644 index 0000000..c7d0316 --- /dev/null +++ b/FinlyticApp/lib/features/simulation/cubit/backtest_cubit.dart @@ -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 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 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? history, + String? historyErrorMessage, + bool clearHistoryError = false, + Map? 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 get props => [ + isLoading, + report, + errorMessage, + isViewingHistoricalRun, + isHistoryLoading, + history, + historyErrorMessage, + parameterOverrides, + isParametersLoading, + parametersMessage, + ]; +} + +class BacktestCubit extends Cubit { + final SimulationRepository repository; + + BacktestCubit({required this.repository}) : super(const BacktestState()); + + Future 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 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 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.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 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 = {}; + 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 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')); + } + } +} diff --git a/FinlyticApp/lib/features/simulation/models/backtest_history_entry_model.dart b/FinlyticApp/lib/features/simulation/models/backtest_history_entry_model.dart new file mode 100644 index 0000000..3c03ee0 --- /dev/null +++ b/FinlyticApp/lib/features/simulation/models/backtest_history_entry_model.dart @@ -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 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 get props => [ + runId, + isin, + symbol, + strategyKey, + timeframe, + startDateUtc, + endDateUtc, + totalTrades, + winRatePercent, + profitFactor, + maxDrawdownPercent, + totalReturnPercent, + sharpeRatio, + createdAtUtc, + ]; +} diff --git a/FinlyticApp/lib/features/simulation/models/backtest_report_model.dart b/FinlyticApp/lib/features/simulation/models/backtest_report_model.dart new file mode 100644 index 0000000..0fa71c4 --- /dev/null +++ b/FinlyticApp/lib/features/simulation/models/backtest_report_model.dart @@ -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 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 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 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 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((e) => EquityPointModel.fromJson(Map.from(e))) + .toList() + : const []; + + 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 get props => [ + runId, + isin, + symbol, + strategyKey, + timeframe, + totalTrades, + winningTrades, + losingTrades, + winRatePercent, + profitFactor, + maxDrawdownPercent, + totalReturnPercent, + expectancyEur, + sharpeRatio, + equityCurve, + ]; +} diff --git a/FinlyticApp/lib/features/simulation/repositories/simulation_repository.dart b/FinlyticApp/lib/features/simulation/repositories/simulation_repository.dart new file mode 100644 index 0000000..1efc638 --- /dev/null +++ b/FinlyticApp/lib/features/simulation/repositories/simulation_repository.dart @@ -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 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? 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.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> 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((e) => BacktestHistoryEntryModel.fromJson(Map.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 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.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?> 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.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 saveStrategyParameters({ + required String isin, + required String strategyKey, + required Map 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}'); + } + } +} diff --git a/FinlyticApp/lib/features/simulation/utils/strategy_explanations.dart b/FinlyticApp/lib/features/simulation/utils/strategy_explanations.dart new file mode 100644 index 0000000..e5fc843 --- /dev/null +++ b/FinlyticApp/lib/features/simulation/utils/strategy_explanations.dart @@ -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> 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 _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)), + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/simulation/utils/strategy_parameter_definitions.dart b/FinlyticApp/lib/features/simulation/utils/strategy_parameter_definitions.dart new file mode 100644 index 0000000..ee65e90 --- /dev/null +++ b/FinlyticApp/lib/features/simulation/utils/strategy_parameter_definitions.dart @@ -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> 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 forStrategy(String strategyKey) => byStrategy[strategyKey] ?? const []; +} diff --git a/FinlyticApp/lib/features/simulation/views/backtest_visualizer_screen.dart b/FinlyticApp/lib/features/simulation/views/backtest_visualizer_screen.dart new file mode 100644 index 0000000..04ae8cf --- /dev/null +++ b/FinlyticApp/lib/features/simulation/views/backtest_visualizer_screen.dart @@ -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 _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 _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().loadHistory(isin: _selectedIsin); + } + + Future _pickAsset() async { + await showDialog( + context: context, + builder: (_) => AssetSearchDialog( + apiClient: widget.apiClient, + onAssetSelected: (isin, name) { + setState(() { + _selectedIsin = isin; + _selectedAssetName = name; + }); + context.read().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().resetParameterOverrides(); + }, + ), + ); + } + + void _runBacktest() { + if (_selectedIsin.trim().isEmpty) return; + + context.read().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( + 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().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().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( + 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( + builder: (context, state) => _buildParameterSection(state), + ), + const SizedBox(height: 16), + BlocBuilder( + 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( + 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() + .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() + .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().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().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), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/trades/models/trade_model.dart b/FinlyticApp/lib/features/trades/models/trade_model.dart index 88eea38..1b5f9cf 100644 --- a/FinlyticApp/lib/features/trades/models/trade_model.dart +++ b/FinlyticApp/lib/features/trades/models/trade_model.dart @@ -1,379 +1,922 @@ import 'package:equatable/equatable.dart'; +/// Client-only, derived indicator (not a server field). See [TradeModel.driftStatus]. +/// +/// `exitAlert` was removed on purpose: it used to be driven by a +/// `hasPendingExitAlert`/`pendingExitReason` pair that no backend DTO ever +/// produces anymore. Keeping the enum value around would let the UI keep a +/// dead branch alive that can never be reached from real data (Rules.md §4). enum DriftStatus { onTrack, trailingActive, driftWarning, - exitAlert, } -class TradeHourlyUpdateModel extends Equatable { - final String recommendation; - final double currentPrice; - final double? suggestedStopLoss; - final double? suggestedTakeProfit; - final double vixValue; - final String reasoning; - final DateTime timestamp; +/// Typed counterpart of the backend `SignalDirection` enum +/// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`), serialized +/// as a JSON string via `JsonStringEnumConverter` (unmodified member name, +/// e.g. `"Buy"`). +enum SignalDirection { + buy, + sell, + neutral; - const TradeHourlyUpdateModel({ - required this.recommendation, - required this.currentPrice, - this.suggestedStopLoss, - this.suggestedTakeProfit, - this.vixValue = 0.0, - required this.reasoning, - required this.timestamp, + static SignalDirection fromJson(dynamic value) { + switch (value) { + case 'Buy': + return SignalDirection.buy; + case 'Sell': + return SignalDirection.sell; + case 'Neutral': + return SignalDirection.neutral; + default: + throw FormatException('Unknown SignalDirection from server: $value'); + } + } + + bool get isLong => this == SignalDirection.buy; + + String get label => switch (this) { + SignalDirection.buy => 'LONG', + SignalDirection.sell => 'SHORT', + SignalDirection.neutral => 'NEUTRAL', + }; +} + +/// Typed counterpart of the backend `TradeStatus` enum +/// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`). +enum TradeStatus { + proposed, + active, + breakEvenTriggered, + tp1Hit, + tp2Hit, + closed, + stoppedOut, + invalidated, + expired; + + static TradeStatus fromJson(dynamic value) { + switch (value) { + case 'Proposed': + return TradeStatus.proposed; + case 'Active': + return TradeStatus.active; + case 'BreakEvenTriggered': + return TradeStatus.breakEvenTriggered; + case 'Tp1Hit': + return TradeStatus.tp1Hit; + case 'Tp2Hit': + return TradeStatus.tp2Hit; + case 'Closed': + return TradeStatus.closed; + case 'StoppedOut': + return TradeStatus.stoppedOut; + case 'Invalidated': + return TradeStatus.invalidated; + case 'Expired': + return TradeStatus.expired; + default: + throw FormatException('Unknown TradeStatus from server: $value'); + } + } + + String get label => switch (this) { + TradeStatus.proposed => 'VORSCHLAG', + TradeStatus.active => 'AKTIV', + TradeStatus.breakEvenTriggered => 'BREAK-EVEN', + TradeStatus.tp1Hit => 'TP1 ERREICHT', + TradeStatus.tp2Hit => 'TP2 ERREICHT', + TradeStatus.closed => 'GESCHLOSSEN', + TradeStatus.stoppedOut => 'AUSGESTOPPT', + TradeStatus.invalidated => 'INVALIDIERT', + TradeStatus.expired => 'ABGELAUFEN', + }; +} + +/// Typed counterpart of the backend `ExecutionMode` enum +/// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`). +enum ExecutionMode { + signalProposal, + manualTradeRepublic, + paperTradingBot; + + static ExecutionMode fromJson(dynamic value) { + switch (value) { + case 'SignalProposal': + return ExecutionMode.signalProposal; + case 'ManualTradeRepublic': + return ExecutionMode.manualTradeRepublic; + case 'PaperTradingBot': + return ExecutionMode.paperTradingBot; + default: + throw FormatException('Unknown ExecutionMode from server: $value'); + } + } + + String get label => switch (this) { + ExecutionMode.signalProposal => 'Signal (ohne Ausführung)', + ExecutionMode.manualTradeRepublic => 'Manuell (Trade Republic)', + ExecutionMode.paperTradingBot => 'Paper-Trading Bot', + }; +} + +/// Typed counterpart of the backend `InstrumentCategoryType` enum +/// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`). +enum InstrumentCategoryType { + stock, + etf, + turboLong, + turboShort, + factorCertificate; + + static InstrumentCategoryType fromJson(dynamic value) { + switch (value) { + case 'Stock': + return InstrumentCategoryType.stock; + case 'Etf': + return InstrumentCategoryType.etf; + case 'TurboLong': + return InstrumentCategoryType.turboLong; + case 'TurboShort': + return InstrumentCategoryType.turboShort; + case 'FactorCertificate': + return InstrumentCategoryType.factorCertificate; + default: + throw FormatException('Unknown InstrumentCategoryType from server: $value'); + } + } + + bool get isDerivative => this == turboLong || this == turboShort || this == factorCertificate; + + String get label => switch (this) { + InstrumentCategoryType.stock => 'Aktie', + InstrumentCategoryType.etf => 'ETF', + InstrumentCategoryType.turboLong => 'Turbo Long', + InstrumentCategoryType.turboShort => 'Turbo Short', + InstrumentCategoryType.factorCertificate => 'Faktor-Zertifikat', + }; +} + +/// Typed counterpart of the backend `ExitStrategyType` enum +/// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`). +enum ExitStrategyType { + stagedScaleOutWithBreakEven, + pureTrailingStop, + dynamicBandTouch, + fixedSingleTarget, + indicatorReversal; + + static ExitStrategyType fromJson(dynamic value) { + switch (value) { + case 'StagedScaleOutWithBreakEven': + return ExitStrategyType.stagedScaleOutWithBreakEven; + case 'PureTrailingStop': + return ExitStrategyType.pureTrailingStop; + case 'DynamicBandTouch': + return ExitStrategyType.dynamicBandTouch; + case 'FixedSingleTarget': + return ExitStrategyType.fixedSingleTarget; + case 'IndicatorReversal': + return ExitStrategyType.indicatorReversal; + default: + throw FormatException('Unknown ExitStrategyType from server: $value'); + } + } + + String get label => switch (this) { + ExitStrategyType.stagedScaleOutWithBreakEven => 'Stufenausstieg mit Break-Even', + ExitStrategyType.pureTrailingStop => 'Reiner Trailing-Stop', + ExitStrategyType.dynamicBandTouch => 'Dynamische Band-Berührung', + ExitStrategyType.fixedSingleTarget => 'Fixes Einzelziel', + ExitStrategyType.indicatorReversal => 'Indikator-Umkehr', + }; +} + +/// Typed counterpart of the backend `TrailingStopType` enum +/// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`). +enum TrailingStopType { + atrMultiplier, + superTrendLine, + swingPoints; + + static TrailingStopType fromJson(dynamic value) { + switch (value) { + case 'AtrMultiplier': + return TrailingStopType.atrMultiplier; + case 'SuperTrendLine': + return TrailingStopType.superTrendLine; + case 'SwingPoints': + return TrailingStopType.swingPoints; + default: + throw FormatException('Unknown TrailingStopType from server: $value'); + } + } + + String get label => switch (this) { + TrailingStopType.atrMultiplier => 'ATR-Multiplikator', + TrailingStopType.superTrendLine => 'SuperTrend-Linie', + TrailingStopType.swingPoints => 'Swing-Punkte', + }; +} + +double _reqNum(Map json, String key) { + final val = json[key]; + if (val is num) return val.toDouble(); + throw FormatException('Expected numeric field "$key" but got: $val'); +} + +int _reqInt(Map json, String key) { + final val = json[key]; + if (val is num) return val.toInt(); + throw FormatException('Expected integer field "$key" but got: $val'); +} + +String _reqStr(Map json, String key) { + final val = json[key]; + if (val is String) return val; + throw FormatException('Expected string field "$key" but got: $val'); +} + +/// Typed counterpart of the backend `TakeProfitStage` record +/// (see `FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs`). +class TakeProfitStageModel extends Equatable { + final int stageNumber; + final double targetPrice; + final double percentToClose; + final double rMultiple; + final String description; + + const TakeProfitStageModel({ + required this.stageNumber, + required this.targetPrice, + required this.percentToClose, + required this.rMultiple, + required this.description, }); - factory TradeHourlyUpdateModel.fromJson(Map 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 ts = DateTime.now(); - final tsStr = (json['timestamp'] ?? json['Timestamp'])?.toString(); - if (tsStr != null && tsStr.isNotEmpty) { - ts = DateTime.tryParse(tsStr) ?? DateTime.now(); - } - - return TradeHourlyUpdateModel( - recommendation: (json['recommendation'] ?? json['Recommendation'])?.toString() ?? 'Hold', - currentPrice: parseDbl(json['currentPrice'] ?? json['CurrentPrice']), - suggestedStopLoss: json['suggestedStopLoss'] != null ? parseDbl(json['suggestedStopLoss'] ?? json['SuggestedStopLoss']) : null, - suggestedTakeProfit: json['suggestedTakeProfit'] != null ? parseDbl(json['suggestedTakeProfit'] ?? json['SuggestedTakeProfit']) : null, - vixValue: parseDbl(json['vixValue'] ?? json['VixValue']), - reasoning: (json['reasoning'] ?? json['Reasoning'])?.toString() ?? '', - timestamp: ts, + factory TakeProfitStageModel.fromJson(Map json) { + return TakeProfitStageModel( + stageNumber: _reqInt(json, 'stageNumber'), + targetPrice: _reqNum(json, 'targetPrice'), + percentToClose: _reqNum(json, 'percentToClose'), + rMultiple: _reqNum(json, 'rMultiple'), + description: _reqStr(json, 'description'), ); } @override - List get props => [recommendation, currentPrice, suggestedStopLoss, suggestedTakeProfit, reasoning, timestamp]; + List get props => [stageNumber, targetPrice, percentToClose, rMultiple, description]; } -class TradeModel extends Equatable { - final String id; - final String analysisId; - final String status; - final bool isGlobalProposal; - final String userId; - final String symbol; - final String isin; - final String companyName; - final String sector; - final String signalType; - final double entryPrice; - final double actualEntryPrice; - final double currentPrice; - final double stopLoss; - final double takeProfit; - final double positionSize; - final double leverageUsed; - final double pnlAbsolute; - final double pnlPercent; - final String reasoning; - final String technicalRationale; - final String fundamentalRationale; - final String riskWarning; - final double winRate; - final String timeframe; - final String instrumentType; - final String assetType; - final bool hasCfd; - final List derivativeProductCategories; - final String derivativeIsin; - final DateTime? createdAt; - - final String riskTolerance; - final double vixValue; - final String vixRegime; - final List takeProfitTargets; - final double maxLeverage; - final double entryZoneMin; - final double entryZoneMax; - final double entryFee; - final double exitFee; - final double quantity; +/// Typed counterpart of the backend `BreakEvenRule` record. +class BreakEvenRuleModel extends Equatable { + final bool enabled; + final double triggerPrice; + final double offsetToCoverFees; - final String closeReason; - final DateTime? userExitTimestamp; - final bool hasPendingExitAlert; - final String pendingExitReason; - final List hourlyUpdates; - - const TradeModel({ - required this.id, - this.analysisId = '', - this.status = 'Active', - this.isGlobalProposal = false, - this.userId = '', - required this.symbol, - this.isin = '', - this.companyName = '', - this.sector = '', - required this.signalType, - required this.entryPrice, - this.actualEntryPrice = 0.0, - this.currentPrice = 0.0, - required this.stopLoss, - required this.takeProfit, - this.positionSize = 0.0, - this.leverageUsed = 1.0, - this.pnlAbsolute = 0.0, - this.pnlPercent = 0.0, - this.reasoning = '', - this.technicalRationale = '', - this.fundamentalRationale = '', - this.riskWarning = '', - this.winRate = 50.0, - this.timeframe = '1D', - this.instrumentType = 'Stock', - this.assetType = 'stock', - this.hasCfd = false, - this.derivativeProductCategories = const [], - this.derivativeIsin = '', - this.createdAt, - this.riskTolerance = 'Moderate', - this.vixValue = 0.0, - this.vixRegime = 'Normal', - this.takeProfitTargets = const [], - this.maxLeverage = 1.0, - this.entryZoneMin = 0.0, - this.entryZoneMax = 0.0, - this.entryFee = 0.0, - this.exitFee = 0.0, - this.quantity = 0.0, - this.closeReason = '', - this.userExitTimestamp, - this.hasPendingExitAlert = false, - this.pendingExitReason = '', - this.hourlyUpdates = const [], + const BreakEvenRuleModel({ + required this.enabled, + required this.triggerPrice, + required this.offsetToCoverFees, }); - bool get isActive => status.toLowerCase() == 'active'; - bool get isClosed => status.toLowerCase() == 'closed'; - bool get isRejected => status.toLowerCase() == 'rejected'; - bool get isProposed => (status.toLowerCase() == 'proposed' || isGlobalProposal) && !isRejected && !isActive && !isClosed; - - DriftStatus get driftStatus { - if (hasPendingExitAlert) return DriftStatus.exitAlert; - if (hourlyUpdates.any((u) => u.recommendation.toLowerCase().contains('adjustsl') || u.recommendation.toLowerCase().contains('trailing'))) { - return DriftStatus.trailingActive; - } - if (calculatedPnlPct < -3.5) return DriftStatus.driftWarning; - return DriftStatus.onTrack; - } - - double get effectiveCurrentPrice { - if (currentPrice > 0) return currentPrice; - if (actualEntryPrice > 0) return actualEntryPrice; - return entryPrice; - } - - double get actualExitPrice => currentPrice; - - double get calculatedPnlAbs { - if (isClosed && pnlAbsolute != 0) return pnlAbsolute; - final curr = currentPrice; - if (curr <= 0) return pnlAbsolute; - final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice; - if (entry <= 0) return 0.0; - final isShort = signalType == 'SELL' || signalType == 'SHORT'; - final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry); - final posSize = positionSize > 0 ? positionSize : (quantity > 0 ? quantity * entry : entry); - final lev = leverageUsed > 0 ? leverageUsed : 1.0; - final fees = entryFee + exitFee; - return (rawMove * posSize * lev) - fees; - } - - double get calculatedPnlPct { - if (isClosed && pnlPercent != 0) return pnlPercent; - final pnlAbs = calculatedPnlAbs; - final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : (entryPrice > 0 ? entryPrice : 1.0)); - if (posSize <= 0) return 0.0; - return (pnlAbs / posSize) * 100.0; - } - - double calculateLivePnlAbs(double livePrice) { - if (isClosed && pnlAbsolute != 0) return pnlAbsolute; - final curr = livePrice > 0 ? livePrice : currentPrice; - if (curr <= 0) return pnlAbsolute; - final entry = actualEntryPrice > 0 ? actualEntryPrice : entryPrice; - if (entry <= 0) return 0.0; - final isShort = signalType == 'SELL' || signalType == 'SHORT'; - final rawMove = isShort ? ((entry - curr) / entry) : ((curr - entry) / entry); - final posSize = positionSize > 0 ? positionSize : (quantity > 0 ? quantity * entry : entry); - final lev = leverageUsed > 0 ? leverageUsed : 1.0; - final fees = entryFee + exitFee; - return (rawMove * posSize * lev) - fees; - } - - double calculateLivePnlPct(double livePrice) { - if (isClosed && pnlPercent != 0) return pnlPercent; - final pnlAbs = calculateLivePnlAbs(livePrice); - final posSize = positionSize > 0 ? positionSize : (actualEntryPrice > 0 ? actualEntryPrice : (entryPrice > 0 ? entryPrice : 1.0)); - if (posSize <= 0) return 0.0; - return (pnlAbs / posSize) * 100.0; - } - - factory TradeModel.fromJson(Map 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 idVal = (json['tradeId'] ?? json['TradeId'] ?? json['id'] ?? json['Id'])?.toString() ?? ''; - final sig = (json['signalType'] ?? json['SignalType'] ?? json['side'] ?? json['Side'])?.toString() ?? 'BUY'; - final entry = parseDbl(json['entryPrice'] ?? json['EntryPrice']); - final actualEntry = parseDbl(json['actualEntryPrice'] ?? json['ActualEntryPrice']); - final currPrice = parseDbl(json['currentPrice'] ?? json['CurrentPrice'] ?? json['price'] ?? json['Price']); - final sl = parseDbl(json['stopLoss'] ?? json['StopLoss']); - final tp = parseDbl(json['takeProfit'] ?? json['TakeProfit']); - final pnlAbs = parseDbl(json['pnlAbsolute'] ?? json['PnlAbsolute'] ?? json['pnl'] ?? json['Pnl']); - final pnlPct = parseDbl(json['pnlPercent'] ?? json['PnlPercent']); - - DateTime? dt; - final createdStr = (json['createdAt'] ?? json['CreatedAt'])?.toString(); - if (createdStr != null && createdStr.isNotEmpty) { - dt = DateTime.tryParse(createdStr); - } - - DateTime? exitDt; - final exitStr = (json['userExitTimestamp'] ?? json['UserExitTimestamp'])?.toString(); - if (exitStr != null && exitStr.isNotEmpty) { - exitDt = DateTime.tryParse(exitStr); - } - - List updates = []; - final rawUpdates = json['hourlyUpdates'] ?? json['HourlyUpdates']; - if (rawUpdates is List) { - updates = rawUpdates.map((u) => TradeHourlyUpdateModel.fromJson(Map.from(u))).toList(); - } - - return TradeModel( - id: idVal, - analysisId: (json['analysisId'] ?? json['AnalysisId'])?.toString() ?? '', - status: (json['status'] ?? json['Status'])?.toString() ?? 'Active', - isGlobalProposal: json['isGlobalProposal'] == true || json['IsGlobalProposal'] == true, - userId: (json['userId'] ?? json['UserId'])?.toString() ?? '', - symbol: (json['symbol'] ?? json['Symbol'])?.toString() ?? 'UNKNOWN', - isin: (json['isin'] ?? json['Isin'])?.toString() ?? '', - companyName: (json['companyName'] ?? json['CompanyName'])?.toString() ?? '', - sector: (json['sector'] ?? json['Sector'])?.toString() ?? '', - signalType: sig.toUpperCase(), - entryPrice: entry, - actualEntryPrice: actualEntry, - currentPrice: currPrice, - stopLoss: sl, - takeProfit: tp, - positionSize: parseDbl(json['positionSize'] ?? json['PositionSize']), - leverageUsed: parseDbl(json['leverageUsed'] ?? json['LeverageUsed']) == 0 ? 1.0 : parseDbl(json['leverageUsed'] ?? json['LeverageUsed']), - pnlAbsolute: pnlAbs, - pnlPercent: pnlPct, - reasoning: (json['reasoning'] ?? json['Reasoning'])?.toString() ?? '', - technicalRationale: (json['technicalRationale'] ?? json['TechnicalRationale'])?.toString() ?? '', - fundamentalRationale: (json['fundamentalRationale'] ?? json['FundamentalRationale'])?.toString() ?? '', - riskWarning: (json['riskWarning'] ?? json['RiskWarning'])?.toString() ?? '', - winRate: parseDbl(json['winRate'] ?? json['WinRate']), - timeframe: (json['timeframe'] ?? json['Timeframe'])?.toString() ?? '1D', - instrumentType: (json['instrumentType'] ?? json['InstrumentType'])?.toString() ?? 'Stock', - assetType: (json['assetType'] ?? json['AssetType'])?.toString() ?? 'stock', - hasCfd: json['hasCfd'] == true || json['HasCfd'] == true, - derivativeProductCategories: (json['derivativeProductCategories'] ?? json['DerivativeProductCategories']) is List - ? ((json['derivativeProductCategories'] ?? json['DerivativeProductCategories']) as List).map((e) => e.toString()).toList() - : const [], - derivativeIsin: (json['derivativeIsin'] ?? json['DerivativeIsin'] ?? json['knockoutIsin'] ?? json['KnockoutIsin'])?.toString() ?? '', - createdAt: dt, - riskTolerance: (json['riskTolerance'] ?? json['RiskTolerance'])?.toString() ?? 'Moderate', - vixValue: parseDbl(json['vixValue'] ?? json['VixValue']), - vixRegime: (json['vixRegime'] ?? json['VixRegime'])?.toString() ?? 'Normal', - takeProfitTargets: (json['takeProfitTargets'] ?? json['TakeProfitTargets']) is List - ? ((json['takeProfitTargets'] ?? json['TakeProfitTargets']) as List).map((e) => parseDbl(e)).toList() - : [], - maxLeverage: parseDbl(json['maxLeverage'] ?? json['MaxLeverage']), - entryZoneMin: parseDbl(json['entryZoneMin'] ?? json['EntryZoneMin']), - entryZoneMax: parseDbl(json['entryZoneMax'] ?? json['EntryZoneMax']), - entryFee: parseDbl(json['entryFee'] ?? json['EntryFee']), - exitFee: parseDbl(json['exitFee'] ?? json['ExitFee']), - quantity: parseDbl(json['quantity'] ?? json['Quantity']), - closeReason: (json['closeReason'] ?? json['CloseReason'])?.toString() ?? '', - userExitTimestamp: exitDt, - hasPendingExitAlert: json['hasPendingExitAlert'] == true || json['HasPendingExitAlert'] == true, - pendingExitReason: (json['pendingExitReason'] ?? json['PendingExitReason'])?.toString() ?? '', - hourlyUpdates: updates, + factory BreakEvenRuleModel.fromJson(Map json) { + return BreakEvenRuleModel( + enabled: json['enabled'] == true, + triggerPrice: _reqNum(json, 'triggerPrice'), + offsetToCoverFees: _reqNum(json, 'offsetToCoverFees'), ); } - Map toJson() { - return { - 'tradeId': id, - 'analysisId': analysisId, - 'status': status, - 'isGlobalProposal': isGlobalProposal, - 'userId': userId, - 'symbol': symbol, - 'isin': isin, - 'companyName': companyName, - 'sector': sector, - 'signalType': signalType, - 'entryPrice': entryPrice, - 'actualEntryPrice': actualEntryPrice, - 'currentPrice': currentPrice, - 'stopLoss': stopLoss, - 'takeProfit': takeProfit, - 'positionSize': positionSize, - 'leverageUsed': leverageUsed, - 'pnlAbsolute': pnlAbsolute, - 'pnlPercent': pnlPercent, - 'reasoning': reasoning, - 'technicalRationale': technicalRationale, - 'fundamentalRationale': fundamentalRationale, - 'riskWarning': riskWarning, - 'winRate': winRate, - 'timeframe': timeframe, - 'instrumentType': instrumentType, - 'assetType': assetType, - 'hasCfd': hasCfd, - 'derivativeProductCategories': derivativeProductCategories, - 'derivativeIsin': derivativeIsin, - 'createdAt': createdAt?.toIso8601String(), - 'riskTolerance': riskTolerance, - 'vixValue': vixValue, - 'vixRegime': vixRegime, - 'takeProfitTargets': takeProfitTargets, - 'maxLeverage': maxLeverage, - 'entryZoneMin': entryZoneMin, - 'entryZoneMax': entryZoneMax, - 'entryFee': entryFee, - 'exitFee': exitFee, - 'quantity': quantity, - 'closeReason': closeReason, - 'userExitTimestamp': userExitTimestamp?.toIso8601String(), - 'hasPendingExitAlert': hasPendingExitAlert, - 'pendingExitReason': pendingExitReason, - }; + @override + List get props => [enabled, triggerPrice, offsetToCoverFees]; +} + +/// Typed counterpart of the backend `TrailingStopRule` record. +class TrailingStopRuleModel extends Equatable { + final TrailingStopType type; + final double multiplier; + final double activationPrice; + final String indicatorKey; + + const TrailingStopRuleModel({ + required this.type, + required this.multiplier, + required this.activationPrice, + required this.indicatorKey, + }); + + factory TrailingStopRuleModel.fromJson(Map json) { + return TrailingStopRuleModel( + type: TrailingStopType.fromJson(json['type']), + multiplier: _reqNum(json, 'multiplier'), + activationPrice: _reqNum(json, 'activationPrice'), + indicatorKey: _reqStr(json, 'indicatorKey'), + ); + } + + @override + List get props => [type, multiplier, activationPrice, indicatorKey]; +} + +/// Typed counterpart of the backend `ReversalCondition` record. +class ReversalConditionModel extends Equatable { + final String ruleDescription; + final String indicatorTrigger; + + const ReversalConditionModel({ + required this.ruleDescription, + required this.indicatorTrigger, + }); + + factory ReversalConditionModel.fromJson(Map json) { + return ReversalConditionModel( + ruleDescription: _reqStr(json, 'ruleDescription'), + indicatorTrigger: _reqStr(json, 'indicatorTrigger'), + ); + } + + @override + List get props => [ruleDescription, indicatorTrigger]; +} + +/// Typed counterpart of the backend `ExitPlan` record +/// (see `FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs`). +/// +/// `takeProfitStages` may legitimately be empty (e.g. [ExitStrategyType.pureTrailingStop] +/// or [ExitStrategyType.indicatorReversal] manage the exit without discrete price +/// targets) — callers MUST treat an empty list as "no fixed TP target", not as +/// missing data to hide behind a fabricated €0.00 (Rules.md §4). +class ExitPlanModel extends Equatable { + final ExitStrategyType strategyType; + final double initialStopLoss; + final List takeProfitStages; + final BreakEvenRuleModel? breakEvenRule; + final TrailingStopRuleModel? trailingStopRule; + final ReversalConditionModel? reversalCondition; + final int? maxHoldingBars; + + const ExitPlanModel({ + required this.strategyType, + required this.initialStopLoss, + required this.takeProfitStages, + this.breakEvenRule, + this.trailingStopRule, + this.reversalCondition, + this.maxHoldingBars, + }); + + factory ExitPlanModel.fromJson(Map json) { + final rawStages = json['takeProfitStages']; + final beRaw = json['breakEvenRule']; + final trailRaw = json['trailingStopRule']; + final revRaw = json['reversalCondition']; + + return ExitPlanModel( + strategyType: ExitStrategyType.fromJson(json['strategyType']), + initialStopLoss: _reqNum(json, 'initialStopLoss'), + takeProfitStages: rawStages is List + ? rawStages.map((s) => TakeProfitStageModel.fromJson(Map.from(s))).toList() + : const [], + breakEvenRule: beRaw is Map ? BreakEvenRuleModel.fromJson(Map.from(beRaw)) : null, + trailingStopRule: trailRaw is Map ? TrailingStopRuleModel.fromJson(Map.from(trailRaw)) : null, + reversalCondition: revRaw is Map ? ReversalConditionModel.fromJson(Map.from(revRaw)) : null, + maxHoldingBars: json['maxHoldingBars'] is num ? (json['maxHoldingBars'] as num).toInt() : null, + ); + } + + @override + List get props => [strategyType, initialStopLoss, takeProfitStages, breakEvenRule, trailingStopRule, reversalCondition, maxHoldingBars]; +} + +/// Typed counterpart of the backend `TradeFillDto` +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`) — one entry of the +/// real execution history of a trade (initial entry fill plus any partial +/// scale-outs). +class TradeFillModel extends Equatable { + final String fillId; + final DateTime executedAtUtc; + final double price; + final double quantity; + final double fee; + final String? note; + + const TradeFillModel({ + required this.fillId, + required this.executedAtUtc, + required this.price, + required this.quantity, + required this.fee, + this.note, + }); + + factory TradeFillModel.fromJson(Map json) { + return TradeFillModel( + fillId: _reqStr(json, 'fillId'), + executedAtUtc: DateTime.parse(_reqStr(json, 'executedAtUtc')), + price: _reqNum(json, 'price'), + quantity: _reqNum(json, 'quantity'), + fee: _reqNum(json, 'fee'), + note: json['note'] as String?, + ); + } + + @override + List get props => [fillId, executedAtUtc, price, quantity, fee, note]; +} + +/// Typed counterpart of the backend `ActiveTradeDto` +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), delivered by +/// `GET /api/v1/user/trades`. +/// +/// Every field below has a direct 1:1 match on `ActiveTradeDto` — no field is +/// carried over from the old (now-removed) FinlyticAnalyzer-era shape. Fields +/// that no longer exist server-side (analysisId, isGlobalProposal, userId, +/// companyName, sector, entryZoneMin/Max, leverageUsed, maxLeverage, hasCfd, +/// riskTolerance, timeframe, vixValue, vixRegime, winRate, reasoning, +/// technicalRationale, fundamentalRationale, riskWarning, hourlyUpdates, +/// closeReason, userExitTimestamp, hasPendingExitAlert, pendingExitReason) +/// were removed rather than kept alive with a default value, because a +/// default here would look like a real (but always-empty/zero) measurement +/// to the UI (Rules.md §4). +class TradeModel extends Equatable { + final String id; + final String proposalId; + final String underlyingIsin; + final String symbol; + + /// Nullable: only set once the engine has bound a concrete Knock-Out/Turbo + /// product to this trade (see [InstrumentCategoryType.isDerivative]). + final String? derivativeIsin; + + /// Nullable: Trade Republic (the only derivative data source in the + /// system) never provides a WKN for its Knock-Out products, only an ISIN + /// — this is always `null` today. It must never be back-filled with the + /// ISIN or an empty string (Rules.md §4). + final String? derivativeWkn; + + final ExecutionMode executionMode; + final InstrumentCategoryType instrumentType; + final SignalDirection direction; + final TradeStatus status; + final double averageBuyIn; + final double totalQuantity; + final double initialStopLoss; + final double currentStopLoss; + final double currentPrice; + final double unrealizedPnlEur; + final double unrealizedPnlPercent; + final double realizedPnlEur; + final ExitPlanModel exitPlan; + final List fills; + final DateTime openedAtUtc; + final DateTime? closedAtUtc; + + const TradeModel({ + required this.id, + required this.proposalId, + required this.underlyingIsin, + required this.symbol, + this.derivativeIsin, + this.derivativeWkn, + required this.executionMode, + required this.instrumentType, + required this.direction, + required this.status, + required this.averageBuyIn, + required this.totalQuantity, + required this.initialStopLoss, + required this.currentStopLoss, + required this.currentPrice, + required this.unrealizedPnlEur, + required this.unrealizedPnlPercent, + required this.realizedPnlEur, + required this.exitPlan, + required this.fills, + required this.openedAtUtc, + this.closedAtUtc, + }); + + /// A row that was accepted but has not received its first fill yet. Not + /// currently produced by the engine's acceptance flow (every accepted + /// proposal starts as [TradeStatus.active] with a real fill), but the + /// status value exists server-side, so the UI must be able to render it. + bool get isProposed => status == TradeStatus.proposed; + + /// A real, currently open position (including break-even/partial-TP + /// states) — as opposed to a status that never resulted in an ongoing + /// position ([isRejected]) or one that has been fully resolved ([isClosed]). + bool get isActive => + status == TradeStatus.active || status == TradeStatus.breakEvenTriggered || status == TradeStatus.tp1Hit || status == TradeStatus.tp2Hit; + + /// Fully resolved with a real (win or loss) outcome on deployed capital. + bool get isClosed => status == TradeStatus.closed || status == TradeStatus.stoppedOut; + + /// Never resulted in a filled/ongoing position — the closest server-side + /// equivalent of the old (removed) "Rejected" status. + bool get isRejected => status == TradeStatus.invalidated || status == TradeStatus.expired; + + bool get isDerivative => instrumentType.isDerivative || (derivativeIsin != null && derivativeIsin!.isNotEmpty); + + /// Derived, client-only display hint — NOT a server field. Unlike the + /// removed `hasPendingExitAlert`/`hourlyUpdates`-driven version, every + /// branch here is backed by real server data: a configured trailing-stop + /// rule, or the server-computed [unrealizedPnlPercent]. + DriftStatus get driftStatus { + if (exitPlan.trailingStopRule != null && isActive) return DriftStatus.trailingActive; + if (isActive && unrealizedPnlPercent < -3.5) return DriftStatus.driftWarning; + return DriftStatus.onTrack; + } + + /// The price a closed trade was actually exited at. The engine sets + /// `CurrentPrice` to the close price at closing time, so this is real data, + /// not a re-derivation — only meaningful once [isClosed]. + double get actualExitPrice => currentPrice; + + /// The euro P&L to display "right now": realized once resolved, otherwise + /// the server's live unrealized figure. Never recomputed client-side. + double get pnlEur => isClosed ? realizedPnlEur : unrealizedPnlEur; + + /// First take-profit target price, or `null` if the exit plan manages the + /// exit without a discrete price target (e.g. pure trailing stop / + /// indicator reversal). Callers MUST show an explicit "no fixed target" + /// state instead of a fabricated €0.00 when this is `null` (Rules.md §4). + double? get primaryTakeProfit => exitPlan.takeProfitStages.isEmpty ? null : exitPlan.takeProfitStages.first.targetPrice; + + factory TradeModel.fromJson(Map json) { + final derivIsin = json['derivativeIsin'] as String?; + final derivWkn = json['derivativeWkn'] as String?; + final rawFills = json['fills']; + + return TradeModel( + id: _reqStr(json, 'tradeId'), + proposalId: _reqStr(json, 'proposalId'), + underlyingIsin: _reqStr(json, 'underlyingIsin'), + symbol: _reqStr(json, 'symbol'), + derivativeIsin: (derivIsin != null && derivIsin.isNotEmpty) ? derivIsin : null, + derivativeWkn: (derivWkn != null && derivWkn.isNotEmpty) ? derivWkn : null, + executionMode: ExecutionMode.fromJson(json['executionMode']), + instrumentType: InstrumentCategoryType.fromJson(json['instrumentType']), + direction: SignalDirection.fromJson(json['direction']), + status: TradeStatus.fromJson(json['status']), + averageBuyIn: _reqNum(json, 'averageBuyIn'), + totalQuantity: _reqNum(json, 'totalQuantity'), + initialStopLoss: _reqNum(json, 'initialStopLoss'), + currentStopLoss: _reqNum(json, 'currentStopLoss'), + currentPrice: _reqNum(json, 'currentPrice'), + unrealizedPnlEur: _reqNum(json, 'unrealizedPnlEur'), + unrealizedPnlPercent: _reqNum(json, 'unrealizedPnlPercent'), + realizedPnlEur: _reqNum(json, 'realizedPnlEur'), + exitPlan: ExitPlanModel.fromJson(Map.from(json['exitPlan'] as Map)), + fills: rawFills is List ? rawFills.map((f) => TradeFillModel.fromJson(Map.from(f))).toList() : const [], + openedAtUtc: DateTime.parse(_reqStr(json, 'openedAtUtc')), + closedAtUtc: json['closedAtUtc'] != null ? DateTime.parse(json['closedAtUtc'] as String) : null, + ); } @override List get props => [ id, - analysisId, - status, - isGlobalProposal, - userId, + proposalId, + underlyingIsin, symbol, - isin, - signalType, - entryPrice, + derivativeIsin, + derivativeWkn, + executionMode, + instrumentType, + direction, + status, + averageBuyIn, + totalQuantity, + initialStopLoss, + currentStopLoss, currentPrice, - pnlAbsolute, - pnlPercent, - hasPendingExitAlert, - hourlyUpdates, + unrealizedPnlEur, + unrealizedPnlPercent, + realizedPnlEur, + exitPlan, + fills, + openedAtUtc, + closedAtUtc, + ]; +} + +/// Typed counterpart of the backend `DerivativeSelectionDto` +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`). +class DerivativeSelectionModel extends Equatable { + final String derivativeIsin; + + /// Nullable: Trade Republic (the only derivative data source in the system) + /// does not provide a WKN for its Knock-Out products, only an ISIN. The + /// backend used to (incorrectly) put the ISIN in this field as a stand-in; + /// it now sends `null` instead. The UI MUST NOT render an empty string as + /// if it were a real identifier (Rules.md §4) — treat `null` as "keine + /// WKN verfügbar" and hide/label the field accordingly. + final String? derivativeWkn; + final String issuer; + final String optionType; + final double strike; + final double barrier; + final double leverage; + final double safetyBufferPercent; + final double spreadPercentage; + final double size; + + const DerivativeSelectionModel({ + this.derivativeIsin = '', + this.derivativeWkn, + this.issuer = '', + this.optionType = '', + this.strike = 0.0, + this.barrier = 0.0, + this.leverage = 0.0, + this.safetyBufferPercent = 0.0, + this.spreadPercentage = 0.0, + this.size = 0.0, + }); + + factory DerivativeSelectionModel.fromJson(Map 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 rawWkn = json['derivativeWkn']?.toString(); + + return DerivativeSelectionModel( + derivativeIsin: json['derivativeIsin']?.toString() ?? '', + derivativeWkn: (rawWkn != null && rawWkn.isNotEmpty) ? rawWkn : null, + issuer: json['issuer']?.toString() ?? '', + optionType: json['optionType']?.toString() ?? '', + strike: parseDbl(json['strike']), + barrier: parseDbl(json['barrier']), + leverage: parseDbl(json['leverage']), + safetyBufferPercent: parseDbl(json['safetyBufferPercent']), + spreadPercentage: parseDbl(json['spreadPercentage']), + size: parseDbl(json['size']), + ); + } + + @override + List get props => [ + derivativeIsin, + derivativeWkn, + issuer, + optionType, + strike, + barrier, + leverage, + safetyBufferPercent, + spreadPercentage, + size, + ]; +} + +/// Typed counterpart of the backend `ValidationSource` enum +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), serialized as a +/// JSON string (`"Ai"` / `"RuleBased"`) via `JsonStringEnumConverter`. +enum ValidationSource { + ai, + ruleBased, + ; + + /// Backend default is `Ai` (enum value 0) — a webhook payload that omits + /// this (new) field entirely must still be interpreted as a real AI result, + /// matching the server-side default. + static ValidationSource fromJson(dynamic value) { + switch (value?.toString()) { + case 'RuleBased': + return ValidationSource.ruleBased; + case 'Ai': + return ValidationSource.ai; + default: + return ValidationSource.ai; + } + } +} + +/// Typed counterpart of the backend `AiValidationResultDto` +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`). +/// +/// `confidence` is nullable and MUST stay that way: it is only populated +/// when [source] is [ValidationSource.ai]. When the engine falls back to a +/// rule-based approval (e.g. the n8n webhook is unreachable), the server +/// sends `confidence: null` — inventing a number here (or defaulting to 0.0) +/// would be exactly the fabricated-metric violation this field was +/// introduced to eliminate (Rules.md §4). +class AiValidationResultModel extends Equatable { + final bool isApproved; + final double? confidence; + final ValidationSource source; + final String thesisSummary; + final String invalidationReason; + final List keyCatalysts; + final List identifiedRisks; + + const AiValidationResultModel({ + this.isApproved = false, + this.confidence, + this.source = ValidationSource.ai, + this.thesisSummary = '', + this.invalidationReason = '', + this.keyCatalysts = const [], + this.identifiedRisks = const [], + }); + + bool get isAiValidated => source == ValidationSource.ai; + bool get hasContent => thesisSummary.trim().isNotEmpty || keyCatalysts.isNotEmpty || identifiedRisks.isNotEmpty; + + factory AiValidationResultModel.fromJson(Map json) { + return AiValidationResultModel( + isApproved: json['isApproved'] == true, + confidence: (json['confidence'] as num?)?.toDouble(), + source: ValidationSource.fromJson(json['validationSource']), + thesisSummary: json['thesisSummary']?.toString() ?? '', + invalidationReason: json['invalidationReason']?.toString() ?? '', + keyCatalysts: (json['keyCatalysts'] as List?)?.map((e) => e.toString()).toList() ?? const [], + identifiedRisks: (json['identifiedRisks'] as List?)?.map((e) => e.toString()).toList() ?? const [], + ); + } + + @override + List get props => [isApproved, confidence, source, thesisSummary, invalidationReason, keyCatalysts, identifiedRisks]; +} + +/// Typed counterpart of the backend `TradeProposalDto` +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), delivered live over +/// `SignalRService.tradeProposalStream` (`/hubs/trade-stream`, event `ReceiveTradeProposal`). +/// +/// `selectedDerivative` and `aiValidation` are intentionally nullable: the +/// server may legitimately omit them (no suitable Knock-Out product found / +/// AI validation not yet run), and the UI MUST show an explicit empty state +/// in that case instead of inventing plausible-looking numbers (Rules.md §4). +class TradeProposalModel extends Equatable { + final String proposalId; + final String underlyingIsin; + final String symbol; + final String strategyKey; + final String direction; + final double qualityScore; + final double compositeScore; + final double currentPrice; + final double entryPrice; + final double invalidationPrice; + final DerivativeSelectionModel? selectedDerivative; + final AiValidationResultModel? aiValidation; + final DateTime? createdAtUtc; + final DateTime? expiresAtUtc; + + const TradeProposalModel({ + required this.proposalId, + required this.underlyingIsin, + required this.symbol, + required this.strategyKey, + required this.direction, + this.qualityScore = 0.0, + this.compositeScore = 0.0, + this.currentPrice = 0.0, + this.entryPrice = 0.0, + this.invalidationPrice = 0.0, + this.selectedDerivative, + this.aiValidation, + this.createdAtUtc, + this.expiresAtUtc, + }); + + bool get isLong => direction.toUpperCase() == 'BUY' || direction.toUpperCase() == 'LONG'; + + factory TradeProposalModel.fromJson(Map 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 derivRaw = json['selectedDerivative']; + final aiRaw = json['aiValidation']; + + return TradeProposalModel( + proposalId: (json['proposalId'] ?? json['id'])?.toString() ?? '', + underlyingIsin: json['underlyingIsin']?.toString() ?? '', + symbol: json['symbol']?.toString() ?? '', + strategyKey: json['strategyKey']?.toString() ?? '', + direction: json['direction']?.toString() ?? 'Buy', + qualityScore: parseDbl(json['qualityScore']), + compositeScore: parseDbl(json['compositeScore']), + currentPrice: parseDbl(json['currentPrice']), + entryPrice: parseDbl(json['entryPrice']), + invalidationPrice: parseDbl(json['invalidationPrice']), + selectedDerivative: derivRaw is Map ? DerivativeSelectionModel.fromJson(Map.from(derivRaw)) : null, + aiValidation: aiRaw is Map ? AiValidationResultModel.fromJson(Map.from(aiRaw)) : null, + createdAtUtc: DateTime.tryParse(json['createdAtUtc']?.toString() ?? ''), + expiresAtUtc: DateTime.tryParse(json['expiresAtUtc']?.toString() ?? ''), + ); + } + + @override + List get props => [ + proposalId, + underlyingIsin, + symbol, + strategyKey, + direction, + qualityScore, + compositeScore, + currentPrice, + entryPrice, + invalidationPrice, + selectedDerivative, + aiValidation, + createdAtUtc, + expiresAtUtc, + ]; +} + +/// Typed counterpart of the backend `AssetEvaluationResultDto` +/// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), returned by +/// `POST /api/v1/analyze/manual` and `POST /api/v1/engine/evaluate`. +/// +/// Unlike the old bare `TradeProposalModel?`/`204` contract, this is always +/// fully populated — even when [proposal] is `null` (the pipeline ran the +/// full evaluation but rejected the opportunity, or could not even find a +/// technical setup for the asset) the real, already-computed scores and AI +/// reasoning are still present, so the caller never has to show silence for +/// "no proposal" (Rules.md §4). [daysToNextEarnings] stays nullable because +/// "unknown/not applicable" and "0 days" are different facts and must not be +/// collapsed into the same number. +class AssetEvaluationResultModel extends Equatable { + final TradeProposalModel? proposal; + final double compositeScore; + final double technicalScore; + final double sentimentScore; + final double fundamentalScore; + final bool passedEarningsLockout; + final int? daysToNextEarnings; + final bool passedDividendGate; + final int? daysToNextExDividend; + final bool aiApproved; + final String aiThesisSummary; + final List aiIdentifiedRisks; + + const AssetEvaluationResultModel({ + this.proposal, + this.compositeScore = 0.0, + this.technicalScore = 0.0, + this.sentimentScore = 0.0, + this.fundamentalScore = 0.0, + this.passedEarningsLockout = true, + this.daysToNextEarnings, + this.passedDividendGate = true, + this.daysToNextExDividend, + this.aiApproved = false, + this.aiThesisSummary = '', + this.aiIdentifiedRisks = const [], + }); + + /// True exactly when the pipeline produced an active trade proposal. + bool get hasProposal => proposal != null; + + factory AssetEvaluationResultModel.fromJson(Map 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 proposalRaw = json['proposal']; + + return AssetEvaluationResultModel( + proposal: proposalRaw is Map ? TradeProposalModel.fromJson(Map.from(proposalRaw)) : null, + compositeScore: parseDbl(json['compositeScore']), + technicalScore: parseDbl(json['technicalScore']), + sentimentScore: parseDbl(json['sentimentScore']), + fundamentalScore: parseDbl(json['fundamentalScore']), + 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, + aiApproved: json['aiApproved'] == true, + aiThesisSummary: json['aiThesisSummary']?.toString() ?? '', + aiIdentifiedRisks: (json['aiIdentifiedRisks'] as List?)?.map((e) => e.toString()).toList() ?? const [], + ); + } + + @override + List get props => [ + proposal, + compositeScore, + technicalScore, + sentimentScore, + fundamentalScore, + passedEarningsLockout, + daysToNextEarnings, + passedDividendGate, + daysToNextExDividend, + aiApproved, + aiThesisSummary, + aiIdentifiedRisks, ]; } diff --git a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart index f7def57..38ba952 100644 --- a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart +++ b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart @@ -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` — 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> 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 data = response.data; + return data.map((json) => TradeProposalModel.fromJson(Map.from(json))).toList(); + } + return []; + } catch (e) { + throw Exception('Vorschläge konnten nicht geladen werden: $e'); + } + } + Future> fetchDerivatives( String isin, { String optionType = 'long', @@ -73,12 +102,13 @@ class TradeRepository { } } - Future 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 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 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'); + } } diff --git a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart index f8a7751..d2737db 100644 --- a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart +++ b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart @@ -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>? _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 _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 _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 _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,179 +234,341 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { child: RefreshIndicator( onRefresh: () async { context.read().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().add(const FetchTrades()), - icon: const Icon(Icons.refresh, color: Colors.white70), - tooltip: 'Trades Aktualisieren', - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state is TradeLoading) { - return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)); - } + child: BlocBuilder( + builder: (context, state) { + if (state is TradeLoading) { + return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)); + } - if (state is TradeError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + if (state is TradeError) { + 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().add(const FetchTrades()), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald), + child: const Text('Erneut Versuchen'), + ), + ], + ), + ); + } + + 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 filteredList = allTrades; + if (_selectedFilter == 'Trades') { + filteredList = bookedTrades; + } else if (_selectedFilter == 'Offen') { + filteredList = activeTrades; + } else if (_selectedFilter == 'Vorschläge') { + filteredList = const []; + } else if (_selectedFilter == 'Geschlossen') { + filteredList = closedTrades; + } else if (_selectedFilter == 'Abgelehnt') { + filteredList = rejectedTrades; + } + + List filteredProposals = _proposals; + + if (_searchQuery.trim().isNotEmpty) { + final q = _searchQuery.toLowerCase().trim(); + filteredList = filteredList.where((t) => + t.symbol.toLowerCase().contains(q) || + t.underlyingIsin.toLowerCase().contains(q)).toList(); + filteredProposals = filteredProposals.where((p) => + p.symbol.toLowerCase().contains(q) || + p.underlyingIsin.toLowerCase().contains(q)).toList(); + } + + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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( + 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().add(const FetchTrades()), - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald), - child: const Text('Erneut Versuchen'), + icon: const Icon(Icons.refresh, color: Colors.white70), + tooltip: 'Trades Aktualisieren', ), ], ), - ); - } - - if (state is TradeLoaded) { - final allTrades = state.trades; - final proposals = 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(); - - List filteredList = allTrades; - if (_selectedFilter == 'Alle') { - filteredList = allTrades.where((t) => !t.isProposed).toList(); - } else if (_selectedFilter == 'Offen') { - filteredList = activeTrades; - } else if (_selectedFilter == 'Vorschläge') { - filteredList = proposals; - } else if (_selectedFilter == 'Geschlossen') { - filteredList = closedTrades; - } else if (_selectedFilter == 'Abgelehnt') { - filteredList = rejectedTrades; - } - - 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(); - } - - return ListView( - children: [ - TradePerformanceBar( - activeTrades: activeTrades, - allTrades: allTrades, - proposals: proposals, - ), - ProposedAutoTradesCard( - proposals: proposals, - onAcceptProposal: (trade) => _handleAcceptProposal(context, trade), - ), - Row( - children: [ - Expanded( - child: TextField( - controller: _searchCtrl, - onChanged: (val) => setState(() => _searchQuery = val), - style: const TextStyle(color: Colors.white, fontSize: 13), - decoration: InputDecoration( - hintText: 'Suche nach Symbol, ISIN oder Name...', - hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 13), - prefixIcon: const Icon(Icons.search, size: 18, color: Colors.white54), - filled: true, - fillColor: Colors.white.withValues(alpha: 0.05), - contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))), - ), + const SizedBox(height: 16), + TradePerformanceBar( + activeTrades: activeTrades, + allTrades: allTrades, + proposals: proposals, + ), + ProposedAutoTradesCard( + proposals: proposals, + onAcceptProposal: (trade) => _handleAcceptProposal(context, trade), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + onChanged: (val) => setState(() => _searchQuery = val), + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + hintText: 'Suche nach Symbol, ISIN oder Name...', + hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18, color: Colors.white54), + filled: true, + fillColor: Colors.white.withValues(alpha: 0.05), + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))), ), ), + ), + ], + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _filterChip('Offen', activeTrades.length), + _filterChip('Vorschläge', _proposals.length), + _filterChip('Geschlossen', closedTrades.length), + _filterChip('Abgelehnt', rejectedTrades.length), + _filterChip('Trades', bookedTrades.length), ], ), - const SizedBox(height: 12), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _filterChip('Offen', activeTrades.length), - _filterChip('Vorschläge', proposals.length), - _filterChip('Geschlossen', closedTrades.length), - _filterChip('Abgelehnt', rejectedTrades.length), - _filterChip('Alle', allTrades.length), - ], - ), + ), + const SizedBox(height: 16), + ]), + ), + ), + if (showingProposalsTab) + ..._buildProposalsSlivers(filteredProposals) + else if (filteredList.isEmpty) + 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 Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), + ], ), - const SizedBox(height: 16), - if (filteredList.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 40), - child: Center( - child: Column( - children: [ - Icon(Icons.inbox, size: 40, color: AppTheme.textMuted), - const SizedBox(height: 8), - Text('Keine Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), - ], - ), - ), - ) - else - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: filteredList.length, - itemBuilder: (context, index) { - final trade = filteredList[index]; - return TradeCard( - trade: trade, - onAccept: () => _handleAcceptProposal(context, trade), - onSettings: () => _handleAcceptProposal(context, trade, isActive: true), - onClose: () => _handleCloseTrade(context, trade), - ); - }, - ), - ], - ); - } + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList.builder( + itemCount: filteredList.length, + itemBuilder: (context, index) { + final trade = filteredList[index]; + return TradeCard( + trade: trade, + onAccept: () => _handleAcceptProposal(context, trade), + onSettings: () => _handleAcceptProposal(context, trade, isActive: true), + onClose: () => _handleCloseTrade(context, trade), + ); + }, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ); + } - return const SizedBox.shrink(); - }, + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + } + + /// 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 _buildProposalsSlivers(List 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> { ); } } - diff --git a/FinlyticApp/lib/features/trades/widgets/proposed_auto_trades_card.dart b/FinlyticApp/lib/features/trades/widgets/proposed_auto_trades_card.dart index 916e0c7..7eddbcf 100644 --- a/FinlyticApp/lib/features/trades/widgets/proposed_auto_trades_card.dart +++ b/FinlyticApp/lib/features/trades/widgets/proposed_auto_trades_card.dart @@ -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,50 +180,46 @@ 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, - style: TextStyle(color: AppTheme.textMuted, fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Text( + topProposal.underlyingIsin, + style: TextStyle(color: AppTheme.textMuted, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ), ], ), - if (topProposal.reasoning.isNotEmpty) ...[ - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.05)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.lightbulb_outline, size: 18, color: AppTheme.accentCyan.withValues(alpha: 0.8)), - const SizedBox(width: 12), - Expanded( - child: Text( - topProposal.reasoning, - style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.05)), ), - ], + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lightbulb_outline, size: 18, color: AppTheme.accentCyan.withValues(alpha: 0.8)), + const SizedBox(width: 12), + Expanded( + child: Text( + '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, + ), + ), + ], + ), + ), const SizedBox(height: 24), diff --git a/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart b/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart index 531f31d..06e0678 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_acceptance_dialog.dart @@ -35,16 +35,15 @@ class _TradeAcceptanceDialogState extends State { @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 { 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 { 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); }, diff --git a/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart b/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart index 6e1cf5b..21b9289 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_calculation_card.dart @@ -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] : []); + 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)', - 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, - ), - ), - ], + Text( + 'Investiertes Kapital: €${posSize.toStringAsFixed(2)} • Exit-Strategie: ${trade.exitPlan.strategyType.label}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), ), - 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, diff --git a/FinlyticApp/lib/features/trades/widgets/trade_card.dart b/FinlyticApp/lib/features/trades/widgets/trade_card.dart index 66755d4..dd1decf 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_card.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_card.dart @@ -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,400 +28,314 @@ 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( - 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; - } + // 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 = trade.currentPrice; + final tpStages = trade.exitPlan.takeProfitStages; + final primaryTp = trade.primaryTakeProfit; - final pnlAbs = livePrice > 0 ? trade.calculateLivePnlAbs(livePrice) : trade.calculatedPnlAbs; - final pnlPct = livePrice > 0 ? trade.calculateLivePnlPct(livePrice) : trade.calculatedPnlPct; - final isPnlPos = pnlAbs >= 0; - final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed; - final currPrice = livePrice > 0 ? livePrice : trade.effectiveCurrentPrice; - - return GlassContainer( - margin: const EdgeInsets.only(bottom: 14), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return GlassContainer( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Signal, Symbol, Drift-Radar & Live PnL + Row( children: [ - // Header Row: Signal, Symbol, Drift-Radar & Live PnL - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: signalColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: signalColor.withValues(alpha: 0.4)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - 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)), - ], - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - 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')), - 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), - decoration: BoxDecoration( - color: Colors.white10, - borderRadius: BorderRadius.circular(4), - ), - child: Text(trade.instrumentType, 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 : ""}', - style: TextStyle(color: AppTheme.textMuted, fontSize: 11), - ), - ], - ), - ), - - // Status / PnL / Drift-Radar Badge - if (isActive || isClosed) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: pnlColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: pnlColor.withValues(alpha: 0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${isPnlPos ? '+' : ''}€${pnlAbs.abs().toStringAsFixed(2)}', - style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14), - ), - Text( - '${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%', - style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold), - ), - ], - ), - ), - ] else if (trade.isRejected) ...[ - StatusBadge(label: 'ABGELEHNT', color: AppTheme.accentRed), - ] else ...[ - StatusBadge(label: 'VORSCHLAG', color: Colors.amber), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: signalColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: signalColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(isBuy ? Icons.trending_up : Icons.trending_down, size: 14, color: signalColor), + const SizedBox(width: 4), + Text(trade.direction.label, style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 12)), ], - ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + 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, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(4), + ), + 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.underlyingIsin.isNotEmpty ? " • ${trade.underlyingIsin}" : ""}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), + ), + ], + ), ), - // Active Drift Radar / Trailing Alert Indicator - if (isActive) ...[ - const SizedBox(height: 10), - _buildDriftRadarBar(trade), - ], - - // PENDING EXIT ALERT BANNER (Zero Auto-Close notification) - if (isActive && trade.hasPendingExitAlert) ...[ - const SizedBox(height: 10), + // Status / PnL / Drift-Radar Badge + if (isActive || isClosed) ...[ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: AppTheme.accentRed.withValues(alpha: 0.15), + color: pnlColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.5)), + border: Border.all(color: pnlColor.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${isPnlPos ? '+' : ''}€${pnlAbs.abs().toStringAsFixed(2)}', + style: TextStyle(color: pnlColor, fontWeight: FontWeight.w900, fontSize: 14), + ), + Text( + '${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%', + style: TextStyle(color: pnlColor, fontSize: 11, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ] else if (trade.isRejected) ...[ + StatusBadge(label: trade.status.label, color: AppTheme.accentRed), + ] else ...[ + StatusBadge(label: trade.status.label, color: Colors.amber), + ], + ], + ), + + // Active Drift Radar / Trailing Alert Indicator + if (isActive) ...[ + const SizedBox(height: 10), + _buildDriftRadarBar(trade), + ], + + const SizedBox(height: 12), + + // Price Metrics Grid with Live Kurs & Trailing SL + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _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', + '€${_fmt(trade.currentStopLoss)}', + AppTheme.accentRed, + ), + _priceItem( + tpStages.length > 1 ? 'TP (1. Stufe)' : 'Take-Profit', + primaryTp != null ? '€${_fmt(primaryTp)}' : 'Trailing-Exit', + AppTheme.primaryEmerald, + ), + ], + ), + ), + + if (tpStages.length > 1) ...[ + const SizedBox(height: 8), + Row( + children: [ + Text( + 'Ziele: ', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 4, + children: tpStages.map((stage) { + final isCurrent = primaryTp != null && (primaryTp - stage.targetPrice).abs() < 0.01; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: isCurrent + ? AppTheme.primaryEmerald.withValues(alpha: 0.2) + : Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isCurrent + ? AppTheme.primaryEmerald + : Colors.white.withValues(alpha: 0.15), + ), + ), + child: Text( + 'TP${stage.stageNumber}: €${_fmt(stage.targetPrice)}', + style: TextStyle( + color: isCurrent ? AppTheme.primaryEmerald : Colors.white70, + fontSize: 10.5, + fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold, + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ], + + // 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, + childrenPadding: const EdgeInsets.only(bottom: 6), + dense: true, + leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18), + title: Text( + 'Ausführungshistorie (${trade.fills.length} Fills)', + style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold), + ), + children: trade.fills.reversed.take(4).map((f) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(6), ), child: Row( children: [ - Icon(Icons.warning_amber_rounded, color: AppTheme.accentRed, size: 20), - const SizedBox(width: 10), + Text( + '${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), 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, - ), - ], + child: Text( + '${_fmt(f.quantity)} Stk. @ €${_fmt(f.price)}', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), + maxLines: 1, + 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)), - ), ], ), - ), - ], + ); + }).toList(), + ), + ], - const SizedBox(height: 12), + const SizedBox(height: 8), - // Price Metrics Grid with Live Kurs & Trailing SL - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - 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( - trade.driftStatus == DriftStatus.trailingActive ? 'Stop (Trailing)' : 'Stop-Loss', - '€${trade.stopLoss.toStringAsFixed(2)}', - AppTheme.accentRed, - ), - _priceItem( - trade.takeProfitTargets.length > 1 ? 'TP (Aktuell)' : 'Take-Profit', - '€${trade.takeProfit.toStringAsFixed(2)}', - AppTheme.primaryEmerald, - ), - ], - ), + // Collapsible Live-Kalkulation & TP-Multi-Target Card + TradeCalculationCard(trade: trade, isCollapsible: true, initiallyExpanded: false), + + const SizedBox(height: 12), + + // Footer Action Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + trade.exitPlan.strategyType.label, + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), ), - if (trade.takeProfitTargets.length > 1) ...[ - const SizedBox(height: 8), - Row( - children: [ - Text( - 'Ziele: ', - style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold), + Row( + children: [ + IconButton( + onPressed: () => TradeDetailModal.show( + context, + trade: trade, + onAccept: onAccept, + onClose: onClose, ), - Expanded( - 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; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: isCurrent - ? AppTheme.primaryEmerald.withValues(alpha: 0.2) - : Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isCurrent - ? AppTheme.primaryEmerald - : Colors.white.withValues(alpha: 0.15), - ), - ), - child: Text( - 'TP${idx + 1}: €${tpVal.toStringAsFixed(2)}', - style: TextStyle( - color: isCurrent ? AppTheme.primaryEmerald : Colors.white70, - fontSize: 10.5, - fontWeight: isCurrent ? FontWeight.w900 : FontWeight.bold, - ), - ), - ); - }).toList(), + icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan), + tooltip: 'Details', + ), + if (isProposed && onAccept != null) ...[ + const SizedBox(width: 6), + ElevatedButton.icon( + onPressed: onAccept, + icon: const Icon(Icons.check_circle_outline, size: 16), + label: const Text('Trade Übernehmen'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryEmerald, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), ), ], - ), - ], - - 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) ...[ - const SizedBox(height: 8), - ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(bottom: 6), - dense: true, - leading: Icon(Icons.history_toggle_off, color: AppTheme.accentCyan, size: 18), - title: Text( - 'KI-Guardian Verlauf (${trade.hourlyUpdates.length} Prüfungen)', - style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.bold), - ), - children: trade.hourlyUpdates.reversed.take(4).map((u) { - return Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(6), + if (isActive && onClose != null) ...[ + const SizedBox(width: 6), + OutlinedButton.icon( + onPressed: onClose, + icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed), + label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), - child: Row( - children: [ - Text( - '${u.timestamp.hour.toString().padLeft(2, '0')}:${u.timestamp.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), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + ), + ], + if (isActive && onSettings != null) ...[ + const SizedBox(width: 6), + IconButton( + onPressed: onSettings, + icon: const Icon(Icons.settings, size: 16, color: Colors.white), + tooltip: 'Einstellungen anpassen', + style: IconButton.styleFrom( + backgroundColor: AppTheme.glassSurface, ), - ); - }).toList(), - ), - ], - - const SizedBox(height: 8), - - // Collapsible Live-Kalkulation & TP-Multi-Target Card - TradeCalculationCard(trade: trade, isCollapsible: true, initiallyExpanded: false), - - const SizedBox(height: 12), - - // Footer Action Row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${trade.timeframe.isNotEmpty ? trade.timeframe : "1D"}${trade.leverageUsed > 1 ? " • ${trade.leverageUsed.toStringAsFixed(1)}x Hebel" : ""}', - style: TextStyle(color: AppTheme.textMuted, fontSize: 11), - ), - - Row( - children: [ - IconButton( - onPressed: () => TradeDetailModal.show( - context, - trade: trade, - onAccept: onAccept, - onClose: onClose, - ), - icon: Icon(Icons.info_outline, size: 18, color: AppTheme.accentCyan), - tooltip: 'KI-Begründung & Details', - ), - if (isProposed && onAccept != null) ...[ - const SizedBox(width: 6), - ElevatedButton.icon( - onPressed: onAccept, - icon: const Icon(Icons.check_circle_outline, size: 16), - label: const Text('Trade Übernehmen'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryEmerald, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - ), - ], - if (isActive && onClose != null) ...[ - const SizedBox(width: 6), - OutlinedButton.icon( - onPressed: onClose, - icon: Icon(Icons.flag_outlined, size: 14, color: AppTheme.accentRed), - label: Text('Position Schließen', style: TextStyle(color: AppTheme.accentRed, fontSize: 12, fontWeight: FontWeight.bold)), - style: OutlinedButton.styleFrom( - side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - ), - ], - if (isActive && onSettings != null) ...[ - const SizedBox(width: 6), - IconButton( - onPressed: onSettings, - icon: const Icon(Icons.settings, size: 16, color: Colors.white), - tooltip: 'Einstellungen anpassen', - style: IconButton.styleFrom( - backgroundColor: AppTheme.glassSurface, - ), - ), - ], - ], - ), + ), + ], ], ), ], ), - ); - }, + ], + ), ); } @@ -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)), + ], ], ); } diff --git a/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart b/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart index 85e6a1b..a10e526 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_closing_cockpit.dart @@ -47,9 +47,7 @@ class _TradeClosingCockpitState extends State { @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 { 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 { 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 { _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), ], ), ), diff --git a/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart b/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart index 6f63c02..bba0e13 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart @@ -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,197 +149,82 @@ 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!)), ], ), ), - 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), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - child: Column( - children: trade.hourlyUpdates.reversed.map((u) { - 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')}', - 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.', - 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), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ), + // 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), + _sectionTitle(Icons.history_toggle_off, 'Ausführungshistorie (${trade.fills.length} Fills)', AppTheme.accentCyan), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Column( + children: trade.fills.reversed.map((f) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDate(f.executedAtUtc), + style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 10), + Expanded( + 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), + ), + ), + ], ), - 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)), - ), - ], - ], + ); + }).toList(), ), ), - ), + ], ], ); } + 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 { ); } } - diff --git a/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart b/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart index 83d006e..94bc167 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart @@ -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,21 +88,13 @@ 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 : ""}', - style: TextStyle(color: AppTheme.textMuted.withValues(alpha: 0.7), fontSize: 11), - ), + Text( + 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), ), ], diff --git a/FinlyticApp/lib/features/trades/widgets/trade_execution_ai_plan_card.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_ai_plan_card.dart index 066412c..bc23c7b 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_execution_ai_plan_card.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_ai_plan_card.dart @@ -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)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(6), ), + child: Text(trade.instrumentType.label, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + Row( + children: [ + Icon(Icons.bolt, size: 14, color: AppTheme.accentCyan), + Text(trade.status.label, style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12)), ], ), - 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)), - ], - ), ], ), 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), - ], - ), - ], ], ), ); diff --git a/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart index 045f6d4..1a2fe54 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_cockpit.dart @@ -1,15 +1,33 @@ 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/status_badge.dart'; -import '../../asset_detail/repositories/asset_repository.dart'; import '../models/trade_model.dart'; import '../models/trade_acceptance_dto.dart'; -import '../models/derivative_item_model.dart'; -import 'derivative_picker_modal.dart'; import 'trade_execution_ai_plan_card.dart'; +import 'trade_calculation_card.dart'; +/// Dialog for either (a) confirming the real execution of a trade proposal +/// (the AI-computed plan — instrument, exit strategy, stop-loss, take-profit +/// stages — is shown read-only via [TradeExecutionAiPlanCard]/ +/// [TradeCalculationCard]; only the price/quantity the user actually got +/// filled at are editable), or (b) — when [isActive] is `true` — reviewing an +/// already-open trade's execution numbers. +/// +/// This used to also let the user redesign the whole trade client-side +/// (instrument type, derivative pick, leverage, entry/exit fees, a +/// client-computed CRV). None of that survived the `ActiveTradeDto` / +/// `TradeAcceptanceDto` migration: the engine now picks the instrument and +/// derivative itself (`TradeProposalDto.selectedDerivative`), computes the +/// full exit plan server-side (`ExitPlan`), and +/// `UserTradesController.AcceptTrade` only ever reads +/// `ActualEntryPrice`/`EntryPrice` and `Quantity`/`PositionSize` from the +/// payload — every other field the old cockpit built (leverage, fees, +/// instrument type, derivative ISIN, timeframe, reasoning) is silently +/// ignored server-side today. Rebuilding client-side leverage/fee math for +/// fields the backend no longer accepts would be exactly the kind of +/// disconnected-from-reality UI Rules.md §4 forbids, so that whole surface +/// was removed rather than kept alive against fields that don't exist +/// anymore (Rules.md §3/§4). class TradeExecutionCockpit extends StatefulWidget { final TradeModel trade; final String defaultSymbol; @@ -51,344 +69,96 @@ class TradeExecutionCockpit extends StatefulWidget { State createState() => _TradeExecutionCockpitState(); } -class _InstrumentOption { - final String label; - final String value; - const _InstrumentOption(this.label, this.value); -} - class _TradeExecutionCockpitState extends State { late TextEditingController _entryPriceCtrl; - late TextEditingController _positionSizeCtrl; - late TextEditingController _leverageCtrl; - late TextEditingController _stopLossCtrl; - late TextEditingController _takeProfitCtrl; - late TextEditingController _derivativeIsinCtrl; - late TextEditingController _entryFeeCtrl; - late TextEditingController _exitFeeCtrl; - - late String _selectedInstrument; - DerivativeItemModel? _selectedDerivative; - double? _derivativePrice; - bool _isFetchingDerivativePrice = false; - - List<_InstrumentOption> get _availableInstruments { - final assetType = widget.trade.assetType.toLowerCase(); - final categories = widget.trade.derivativeProductCategories; - final hasCfd = widget.trade.hasCfd; - final list = <_InstrumentOption>[]; - - if (assetType == 'crypto') { - list.add(const _InstrumentOption('Krypto (Spot)', 'Crypto')); - if (hasCfd) { - list.add(const _InstrumentOption('Krypto CFD', 'CFD')); - } - } else if (assetType == 'etf') { - list.add(const _InstrumentOption('ETF (Spot)', 'Stock')); - if (categories.isEmpty || categories.contains('knockOutProduct')) { - list.add(const _InstrumentOption('⚡ Knock-Out (Turbo)', 'KnockOut')); - } - if (categories.contains('vanillaWarrant')) { - list.add(const _InstrumentOption('Optionsschein', 'Option')); - } - if (categories.contains('factorCertificate')) { - list.add(const _InstrumentOption('Faktor-Zertifikat', 'Factor')); - } - if (hasCfd) { - list.add(const _InstrumentOption('CFD', 'CFD')); - } - } else { - // Default: Stock or other - list.add(const _InstrumentOption('Aktie (Spot)', 'Stock')); - if (categories.isEmpty || categories.contains('knockOutProduct')) { - list.add(const _InstrumentOption('⚡ Knock-Out (Turbo)', 'KnockOut')); - } - if (categories.contains('vanillaWarrant')) { - list.add(const _InstrumentOption('Optionsschein', 'Option')); - } - if (categories.contains('factorCertificate')) { - list.add(const _InstrumentOption('Faktor-Zertifikat', 'Factor')); - } - if (hasCfd) { - list.add(const _InstrumentOption('CFD', 'CFD')); - } - } - - return list; - } - - String _formatCurrencyNum(double val) { - if (val <= 0) return '0'; - if (val == val.roundToDouble()) { - return val.toInt().toString(); - } - var s = val.toStringAsFixed(2); - if (s.endsWith('0')) { - s = s.substring(0, s.length - 1); - } - return s.replaceAll('.', ','); - } - - 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'; - } - - String _formatLeverageNum(double lev) { - if (lev <= 0) return '1'; - if (lev == lev.roundToDouble()) { - return lev.toInt().toString(); - } - var s = lev.toStringAsFixed(2); - if (s.endsWith('0')) { - s = s.substring(0, s.length - 1); - } - return s.replaceAll('.', ','); - } + late TextEditingController _quantityCtrl; @override void initState() { super.initState(); - final initEntry = widget.trade.actualEntryPrice > 0 ? widget.trade.actualEntryPrice : (widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 100.0); - final initPos = widget.trade.positionSize > 0 ? widget.trade.positionSize : 1000.0; - final initLev = widget.trade.leverageUsed > 0 ? widget.trade.leverageUsed : (widget.trade.maxLeverage > 0 ? widget.trade.maxLeverage : 1.0); - _entryPriceCtrl = TextEditingController(text: initEntry.toStringAsFixed(2)); - _positionSizeCtrl = TextEditingController(text: _formatCurrencyNum(initPos)); - _leverageCtrl = TextEditingController(text: _formatLeverageNum(initLev)); - _stopLossCtrl = TextEditingController(text: widget.trade.stopLoss > 0 ? widget.trade.stopLoss.toStringAsFixed(2) : ''); - _takeProfitCtrl = TextEditingController(text: widget.trade.takeProfit > 0 ? widget.trade.takeProfit.toStringAsFixed(2) : ''); - _derivativeIsinCtrl = TextEditingController(text: widget.trade.derivativeIsin); - _entryFeeCtrl = TextEditingController(text: widget.trade.entryFee >= 0 ? _formatCurrencyNum(widget.trade.entryFee > 0 ? widget.trade.entryFee : 1.0) : '1.00'); - _exitFeeCtrl = TextEditingController(text: widget.trade.exitFee >= 0 ? _formatCurrencyNum(widget.trade.exitFee > 0 ? widget.trade.exitFee : 1.0) : '1.00'); + // Both fields are prefilled from real, already-known server data where + // available — never from an invented placeholder (Rules.md §4). For an + // already-active trade both numbers are simply the engine's own record + // of what happened (`averageBuyIn`/`totalQuantity`). For a still-proposed + // trade there is no filled quantity yet, so that field starts empty and + // the user must supply the real number — showing e.g. a default of 1000 + // would look like a real suggested size when it is not. + final prefillPrice = widget.isActive + ? widget.trade.averageBuyIn + : (widget.trade.currentPrice > 0 ? widget.trade.currentPrice : widget.trade.averageBuyIn); - final available = _availableInstruments; - final normalized = _normalizeInstrumentType(widget.trade.instrumentType); - if (available.any((opt) => opt.value == normalized)) { - _selectedInstrument = normalized; - } else { - _selectedInstrument = available.isNotEmpty ? available.first.value : 'Stock'; - } - - if (widget.trade.derivativeIsin.isNotEmpty) { - _fetchLiveDerivativePrice(widget.trade.derivativeIsin); - } + _entryPriceCtrl = TextEditingController(text: prefillPrice > 0 ? prefillPrice.toStringAsFixed(2) : ''); + _quantityCtrl = TextEditingController( + text: widget.isActive && widget.trade.totalQuantity > 0 ? widget.trade.totalQuantity.toString() : '', + ); _entryPriceCtrl.addListener(() => setState(() {})); - _positionSizeCtrl.addListener(() => setState(() {})); - _leverageCtrl.addListener(() => setState(() {})); - _stopLossCtrl.addListener(() => setState(() {})); - _takeProfitCtrl.addListener(() => setState(() {})); - _entryFeeCtrl.addListener(() => setState(() {})); - _exitFeeCtrl.addListener(() => setState(() {})); + _quantityCtrl.addListener(() => setState(() {})); } @override void dispose() { _entryPriceCtrl.dispose(); - _positionSizeCtrl.dispose(); - _leverageCtrl.dispose(); - _stopLossCtrl.dispose(); - _takeProfitCtrl.dispose(); - _derivativeIsinCtrl.dispose(); - _entryFeeCtrl.dispose(); - _exitFeeCtrl.dispose(); + _quantityCtrl.dispose(); super.dispose(); } - String _normalizeInstrumentType(String raw) { - final clean = raw.toLowerCase().trim(); - if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) return 'KnockOut'; - if (clean.contains('factor') || clean.contains('faktor')) return 'Factor'; - if (clean.contains('option') || clean.contains('warrant')) return 'Option'; - if (clean.contains('cfd')) return 'CFD'; - if (clean.contains('crypto') || clean.contains('krypto')) return 'Crypto'; - return 'Stock'; - } - - double _parse(TextEditingController ctrl, double fallback) { + double? _parsePositive(TextEditingController ctrl) { final clean = ctrl.text.replaceAll(',', '.').trim(); - return double.tryParse(clean) ?? fallback; + final val = double.tryParse(clean); + return (val != null && val > 0) ? val : null; } - double get _entryPrice => _parse(_entryPriceCtrl, widget.trade.entryPrice > 0 ? widget.trade.entryPrice : 1.0); - double get _positionSize => _parse(_positionSizeCtrl, 1000.0); - double get _leverage => _parse(_leverageCtrl, 1.0); - double get _stopLoss => _parse(_stopLossCtrl, widget.trade.stopLoss); - double get _takeProfit => _parse(_takeProfitCtrl, widget.trade.takeProfit); - double get _entryFee => _parse(_entryFeeCtrl, 1.0); - double get _exitFee => _parse(_exitFeeCtrl, 1.0); - double get _totalFees => _entryFee + _exitFee; + double? get _entryPrice => _parsePositive(_entryPriceCtrl); + double? get _quantity => _parsePositive(_quantityCtrl); - double get _effectiveLeverage { - final isLeveraged = _selectedInstrument == 'KnockOut' || - _selectedInstrument == 'Option' || - _selectedInstrument == 'Factor' || - _selectedInstrument == 'CFD'; - return isLeveraged && _leverage > 0 ? _leverage : 1.0; + /// Straightforward price × quantity of the two values the user is about to + /// submit — not a P&L figure and not derived from any server P&L field, so + /// recomputing it client-side does not create a second, competing P&L + /// calculation (Rules.md's "don't recompute P&L" concern is about + /// `unrealizedPnlEur`/`realizedPnlEur`, which this never touches). + double? get _investedCapital { + final p = _entryPrice; + final q = _quantity; + return (p != null && q != null) ? p * q : null; } - double get _quantity => (_entryPrice > 0 && _positionSize > 0) ? (_positionSize / _entryPrice) : 0.0; - int get _derivativeQuantity => (_derivativePrice != null && _derivativePrice! > 0 && _positionSize > 0) ? (_positionSize / _derivativePrice!).floor() : 0; - - double get _riskAmountAbs { - if (_entryPrice <= 0 || _stopLoss <= 0) return 0.0; - final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; - final movePct = isShort ? ((_stopLoss - _entryPrice) / _entryPrice) : ((_entryPrice - _stopLoss) / _entryPrice); - - // Leveraged loss on position - final rawLoss = (movePct * _positionSize * _effectiveLeverage).abs(); - - // Knock-Out and options cannot lose more than the invested position capital (Totalverlust-Kappung) - final isDerivative = _selectedInstrument == 'KnockOut' || _selectedInstrument == 'Option' || _selectedInstrument == 'Factor'; - final cappedLoss = isDerivative ? rawLoss.clamp(0.0, _positionSize) : rawLoss; - - return cappedLoss + _totalFees; - } - - double get _rewardAmountAbs { - if (_entryPrice <= 0 || _takeProfit <= 0) return 0.0; - final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; - final movePct = isShort ? ((_entryPrice - _takeProfit) / _entryPrice) : ((_takeProfit - _entryPrice) / _entryPrice); - - final rawProfit = (movePct * _positionSize * _effectiveLeverage); - final profitAfterFees = rawProfit - _totalFees; - return profitAfterFees > 0 ? profitAfterFees : 0.0; - } - - double get _crv { - if (_riskAmountAbs <= 0 || _rewardAmountAbs <= 0) return 0.0; - return _rewardAmountAbs / _riskAmountAbs; - } - - List get _availableTpTargets { - if (widget.trade.takeProfitTargets.isNotEmpty) { - return widget.trade.takeProfitTargets; - } - if (widget.trade.takeProfit > 0) { - return [widget.trade.takeProfit]; - } - return []; - } - - double _calculateRewardForTarget(double targetPrice) { - if (_entryPrice <= 0 || targetPrice <= 0) return 0.0; - final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; - final movePct = isShort ? ((_entryPrice - targetPrice) / _entryPrice) : ((targetPrice - _entryPrice) / _entryPrice); - final rawProfit = (movePct * _positionSize * _effectiveLeverage); - final profitAfterFees = rawProfit - _totalFees; - return profitAfterFees > 0 ? profitAfterFees : 0.0; - } - - double _calculateCrvForTarget(double targetPrice) { - final reward = _calculateRewardForTarget(targetPrice); - if (_riskAmountAbs <= 0 || reward <= 0) return 0.0; - return reward / _riskAmountAbs; - } - - Future _openDerivativeFinder() async { - final isinVal = widget.trade.isin.isNotEmpty ? widget.trade.isin : widget.defaultSymbol; - final symVal = widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol; - final nameVal = widget.trade.companyName.isNotEmpty ? widget.trade.companyName : symVal; - final priceVal = _entryPrice > 0 ? _entryPrice : (widget.trade.currentPrice > 0 ? widget.trade.currentPrice : widget.trade.entryPrice); - - final selected = await DerivativePickerModal.show( - context, - underlyingIsin: isinVal, - underlyingSymbol: symVal, - underlyingName: nameVal, - initialSignalType: widget.trade.signalType, - currentUnderlyingPrice: priceVal, - ); - - if (selected != null) { - setState(() { - _selectedDerivative = selected; - _derivativeIsinCtrl.text = selected.isin; - _selectedInstrument = 'KnockOut'; - if (selected.leverage > 0) { - _leverageCtrl.text = _formatLeverageNum(selected.leverage); - } - }); - _fetchLiveDerivativePrice(selected.isin); - } - } - - Future _fetchLiveDerivativePrice(String isin) async { - if (isin.trim().isEmpty) return; - setState(() => _isFetchingDerivativePrice = true); - - try { - final apiClient = context.read(); - final assetRepo = AssetRepository(apiClient: apiClient); - final technicals = await assetRepo.getAssetTechnical(isin.trim().toUpperCase(), 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 && mounted) { - setState(() { - _derivativePrice = fetchedPrice; - }); - } - } catch (_) { - } finally { - if (mounted) setState(() => _isFetchingDerivativePrice = false); - } - } + bool get _canSubmit => _entryPrice != null && _quantity != null; TradeAcceptanceDto _buildDto() { - final isinVal = widget.trade.isin.isNotEmpty ? widget.trade.isin : (widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol); + // For an accept action the payload identifier must be the *proposal* id + // (`UserTradesController.AcceptTrade` parses `tradeId` as a proposal + // Guid and calls `engine_AcceptProposal`), which is `trade.proposalId` + // here — not `trade.id` (the already-existing `ActiveTradeDto.TradeId`). + // For the settings-review path on an already-active trade there is no + // separate proposal to target, so the trade's own id is used instead. + final identifier = widget.isActive ? widget.trade.id : widget.trade.proposalId; + final isinVal = widget.trade.underlyingIsin.isNotEmpty ? widget.trade.underlyingIsin : widget.defaultSymbol; final symbolVal = widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol; return TradeAcceptanceDto( - userId: widget.trade.userId, - tradeId: widget.trade.id, - analysisId: widget.trade.analysisId, + userId: '', // Discarded server-side in favor of the JWT identity anyway. + tradeId: identifier, isin: isinVal, symbol: symbolVal, actualEntryPrice: _entryPrice, - positionSize: _positionSize, - leverageUsed: _leverage, - entryFee: _entryFee, - exitFee: _exitFee, quantity: _quantity, executionTimestamp: DateTime.now().toUtc(), - signalType: widget.trade.signalType, - entryPrice: widget.trade.entryPrice, - stopLoss: _stopLoss, - takeProfit: _takeProfit, - instrumentType: _selectedInstrument, - derivativeIsin: _derivativeIsinCtrl.text.trim(), - timeframe: widget.trade.timeframe, - reasoning: widget.trade.reasoning, ); } @override Widget build(BuildContext context) { - final isBuy = widget.trade.signalType.toUpperCase() == 'BUY' || widget.trade.signalType.toUpperCase() == 'LONG'; - final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; + final isLong = widget.trade.direction.isLong; + final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed; return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Container( - width: 640, - constraints: const BoxConstraints(maxHeight: 820), + width: 560, + constraints: const BoxConstraints(maxHeight: 780), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(24), @@ -404,18 +174,18 @@ class _TradeExecutionCockpitState extends State { padding: const EdgeInsets.fromLTRB(20, 18, 16, 14), child: Row( children: [ - StatusBadge(label: widget.trade.signalType.toUpperCase(), color: signalColor), + StatusBadge(label: widget.trade.direction.label, color: signalColor), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.isActive ? 'Einstellungen für Trade #${widget.trade.id}' : '1-Click Trade Execution Cockpit', + widget.isActive ? 'Ausführungsdetails – Trade #${widget.trade.id}' : '1-Click Trade Execution Cockpit', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), ), Text( - '${widget.trade.companyName.isNotEmpty ? widget.trade.companyName : widget.defaultSymbol} (${widget.trade.isin.isNotEmpty ? widget.trade.isin : widget.defaultSymbol})', + '${widget.trade.symbol.isNotEmpty ? widget.trade.symbol : widget.defaultSymbol} (${widget.trade.underlyingIsin.isNotEmpty ? widget.trade.underlyingIsin : widget.defaultSymbol})', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), ], @@ -437,410 +207,35 @@ class _TradeExecutionCockpitState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // AI Plan Summary + // Read-only AI-computed plan (instrument, exit strategy, + // stop-loss, take-profit stages — all real server fields). TradeExecutionAiPlanCard(trade: widget.trade), + const SizedBox(height: 12), + TradeCalculationCard(trade: widget.trade, isCollapsible: false), const SizedBox(height: 18), - // SECTION 1: INSTRUMENT & DERIVATIVES - const Text('1. FINANZINSTRUMENT & DERIVATE', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), - const SizedBox(height: 10), - - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _availableInstruments.map((opt) { - return _instrumentChip(opt.label, opt.value); - }).toList(), - ), - ), - const SizedBox(height: 12), - - // Derivat Picker Button - if (_selectedInstrument == 'KnockOut' || _selectedInstrument == 'Option' || _selectedInstrument == 'Factor') ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.accentCyan.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - Icon(Icons.bolt, color: AppTheme.accentCyan, size: 20), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _selectedDerivative != null - ? '${_selectedDerivative!.issuerDisplayName} ${_selectedDerivative!.productCategoryName} (${_formatLeverage(_selectedDerivative!.leverage)})' - : (_derivativeIsinCtrl.text.isNotEmpty ? 'Derivat ISIN: ${_derivativeIsinCtrl.text}' : 'Kein Derivat gewählt (Basiswert aktiv)'), - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), - ), - if (_selectedDerivative != null) ...[ - const SizedBox(height: 2), - Wrap( - spacing: 8, - children: [ - if (_selectedDerivative!.barrier > 0) - Text('KO-Schwelle: €${_selectedDerivative!.barrier.toStringAsFixed(2)}', style: const TextStyle(color: Colors.orangeAccent, fontSize: 11)), - if (_derivativePrice != null && _derivativePrice! > 0) - Text('Derivatkurs: €${_derivativePrice!.toStringAsFixed(2)} ($_derivativeQuantity Stk.)', style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold)), - ], - ), - ], - ], - ), - ), - ElevatedButton.icon( - onPressed: _openDerivativeFinder, - icon: const Icon(Icons.search, size: 14), - label: Text(_selectedDerivative != null ? 'Ändern' : 'Derivat wählen', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - ], - - // SECTION 2: POSITION & KAPITAL - const Text('2. INVESTITION & POSITION', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), - const SizedBox(height: 10), - - // Quick Capital Pills - Row( - children: [ - _capitalPill('250 €', 250), - _capitalPill('500 €', 500), - _capitalPill('1.000 €', 1000), - _capitalPill('2.500 €', 2500), - _capitalPill('5.000 €', 5000), - ], + Text( + widget.isActive ? 'AUSFÜHRUNGSDATEN DIESES TRADES' : 'DEINE TATSÄCHLICHE AUSFÜHRUNG', + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5), ), const SizedBox(height: 10), Row( children: [ - Expanded( - child: _buildInput('Investitionsbetrag (€)', _positionSizeCtrl, Icons.account_balance_wallet), - ), + Expanded(child: _buildInput('Ausführungskurs (€)', _entryPriceCtrl, Icons.price_change)), const SizedBox(width: 12), - Expanded( - child: _buildInput( - 'Einstiegskurs (€)', - _entryPriceCtrl, - Icons.price_change, - suffixWidget: _isFetchingDerivativePrice - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.cyan)) - : null, - ), - ), + Expanded(child: _buildInput('Stückzahl', _quantityCtrl, Icons.numbers)), ], ), - const SizedBox(height: 12), - - // Stop-Loss & Take-Profit - Row( - children: [ - Expanded(child: _buildInput('Stop-Loss (€)', _stopLossCtrl, Icons.shield_outlined, accentColor: AppTheme.accentRed)), - const SizedBox(width: 12), - Expanded(child: _buildInput('Take-Profit (€)', _takeProfitCtrl, Icons.trending_up, accentColor: AppTheme.primaryEmerald)), - ], + const SizedBox(height: 10), + Text( + _investedCapital != null + ? 'Eingesetztes Kapital: €${_investedCapital!.toStringAsFixed(2)}' + : 'Bitte Ausführungskurs und Stückzahl angeben.', + style: TextStyle(color: AppTheme.textMuted, fontSize: 11), ), - if (_availableTpTargets.isNotEmpty) ...[ - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'KI-Ziele (TP-Stufen):', - style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold), - ), - const SizedBox(width: 8), - Expanded( - child: Wrap( - spacing: 6, - runSpacing: 4, - children: _availableTpTargets.asMap().entries.map((entry) { - final idx = entry.key; - final targetPrice = entry.value; - final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; - final diffPct = _entryPrice > 0 - ? (isShort ? ((_entryPrice - targetPrice) / _entryPrice * 100) : ((targetPrice - _entryPrice) / _entryPrice * 100)) - : 0.0; - final isSelected = (_takeProfit - targetPrice).abs() < 0.001; - return InkWell( - onTap: () { - setState(() { - _takeProfitCtrl.text = targetPrice.toStringAsFixed(2); - }); - }, - borderRadius: BorderRadius.circular(8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.primaryEmerald.withValues(alpha: 0.2) - : Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isSelected - ? AppTheme.primaryEmerald - : Colors.white.withValues(alpha: 0.15), - width: isSelected ? 1.5 : 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'TP${idx + 1}: €${targetPrice.toStringAsFixed(2)}', - style: TextStyle( - color: isSelected ? AppTheme.primaryEmerald : Colors.white, - fontSize: 11, - fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold, - ), - ), - if (diffPct != 0) ...[ - const SizedBox(width: 4), - Text( - '(${diffPct >= 0 ? "+" : ""}${diffPct.toStringAsFixed(1)}%)', - style: TextStyle( - color: diffPct >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ), - ), - ); - }).toList(), - ), - ), - ], - ), - ], const SizedBox(height: 18), - - // SECTION 3: AUTOMATISCH ERRECHNETE KENNZAHLEN (ZONE 2) - const Text('3. LIVE-KALKULATION (AUTOMATISCH)', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 0.5)), - const SizedBox(height: 10), - - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _statTile( - _selectedInstrument == 'KnockOut' || _selectedInstrument == 'Option' || _selectedInstrument == 'Factor' - ? 'Stückzahl (${_derivativePrice != null ? "Derivat" : "Basiswert"})' - : 'Stückzahl', - _derivativePrice != null && _derivativePrice! > 0 - ? '$_derivativeQuantity Stk.' - : '${_quantity.toStringAsFixed(2)} Stk.', - Colors.white, - ), - _statTile('Max. Verlust (SL)', '-€${_riskAmountAbs.toStringAsFixed(2)}', AppTheme.accentRed), - _statTile('Gewinn-Potenzial (TP)', '+€${_rewardAmountAbs.toStringAsFixed(2)}', AppTheme.primaryEmerald), - _statTile('Chance-Risiko (CRV)', _crv > 0 ? '1 : ${_crv.toStringAsFixed(2)}' : '-', AppTheme.accentCyan), - ], - ), - const Divider(color: Colors.white10, height: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Pauschalgebühren (€${_totalFees.toStringAsFixed(2)} gesamt):', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), - if (_effectiveLeverage > 1.0) - Text('Effektiver Hebel: ${_formatLeverage(_effectiveLeverage)}', style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold)), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: _buildSmallFeeInput('Kauf (€)', _entryFeeCtrl), - ), - const SizedBox(width: 8), - Expanded( - child: _buildSmallFeeInput('Verkauf (€)', _exitFeeCtrl), - ), - const SizedBox(width: 8), - InkWell( - onTap: () { - setState(() { - if (_entryFee == 0) { - _entryFeeCtrl.text = '1,00'; - } else { - _entryFeeCtrl.text = '0,00'; - } - }); - }, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: _entryFee == 0 ? AppTheme.accentCyan.withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _entryFee == 0 ? AppTheme.accentCyan : Colors.white12), - ), - child: Text( - _entryFee == 0 ? '✓ Im Investitionsbetrag enthalten' : 'Kaufgebühr im Betrag enthalten?', - style: TextStyle( - color: _entryFee == 0 ? AppTheme.accentCyan : AppTheme.textMuted, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - 'ℹ️ Hinweis: Rechnerischer Näherungswert. Der effektive Hebel eines Derivats verändert sich dynamisch mit dem Kurs des Basiswerts (Omega/Delta).', - style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontStyle: FontStyle.italic), - ), - ], - ), - - // MULTI-TARGET GEWINNSTUFEN (TP1 - TP3) - if (_availableTpTargets.length > 1) ...[ - const Divider(color: Colors.white10, height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '🎯 MEHRSTUFIGE GEWINN-KALKULATION', - style: TextStyle( - color: AppTheme.primaryEmerald, - fontSize: 11, - fontWeight: FontWeight.w900, - letterSpacing: 0.5, - ), - ), - Text( - 'Klick zum Aktivieren', - style: TextStyle(color: AppTheme.textMuted, fontSize: 10), - ), - ], - ), - const SizedBox(height: 8), - ..._availableTpTargets.asMap().entries.map((entry) { - final idx = entry.key; - final targetPrice = entry.value; - final isSelected = (_takeProfit - targetPrice).abs() < 0.001; - final reward = _calculateRewardForTarget(targetPrice); - final crv = _calculateCrvForTarget(targetPrice); - final isShort = widget.trade.signalType.toUpperCase() == 'SELL' || widget.trade.signalType.toUpperCase() == 'SHORT'; - final movePct = _entryPrice > 0 - ? (isShort ? ((_entryPrice - targetPrice) / _entryPrice * 100) : ((targetPrice - _entryPrice) / _entryPrice * 100)) - : 0.0; - final retPct = _positionSize > 0 ? (reward / _positionSize * 100) : 0.0; - - return InkWell( - onTap: () { - setState(() { - _takeProfitCtrl.text = targetPrice.toStringAsFixed(2); - }); - }, - borderRadius: BorderRadius.circular(8), - child: Container( - margin: const EdgeInsets.only(bottom: 5), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.primaryEmerald.withValues(alpha: 0.14) - : Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isSelected - ? AppTheme.primaryEmerald.withValues(alpha: 0.7) - : Colors.white.withValues(alpha: 0.07), - width: isSelected ? 1.5 : 1, - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected ? AppTheme.primaryEmerald : Colors.white12, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'TP${idx + 1}', - style: TextStyle( - color: isSelected ? Colors.black : Colors.white, - fontSize: 10, - fontWeight: FontWeight.w900, - ), - ), - ), - const SizedBox(width: 8), - Text( - '€${targetPrice.toStringAsFixed(2)}', - style: TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), - const SizedBox(width: 6), - Text( - '(${movePct >= 0 ? "+" : ""}${movePct.toStringAsFixed(1)}% Basiswert)', - style: TextStyle(color: AppTheme.textMuted, fontSize: 10.5), - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '+€${reward.toStringAsFixed(2)} (+${retPct.toStringAsFixed(1)}%)', - style: TextStyle( - color: AppTheme.primaryEmerald, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'CRV 1 : ${crv > 0 ? crv.toStringAsFixed(2) : "-"}', - style: TextStyle(color: AppTheme.accentCyan, fontSize: 10), - ), - ], - ), - ], - ), - ), - ); - }), - ], - ], - ), - ), - const SizedBox(height: 14), - - // SECTION 4: RECHTLICHER DISCLAIMER Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -878,26 +273,33 @@ class _TradeExecutionCockpitState extends State { ), const Spacer(), if (!widget.isActive && widget.onReject != null) ...[ + // This only hides the card locally — the proposal is a + // system-wide opportunity anyone may still accept, and + // there is no server-side rejection anymore. Label and + // color must not suggest an irreversible/destructive + // action that doesn't actually happen (Rules.md §4). OutlinedButton.icon( onPressed: () { widget.onReject!(widget.trade.id); Navigator.pop(context); }, - icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16), - label: Text('Ablehnen', style: TextStyle(color: AppTheme.accentRed)), - style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed.withValues(alpha: 0.5))), + icon: Icon(Icons.visibility_off_outlined, color: AppTheme.textMuted, size: 16), + label: Text('Verwerfen', style: TextStyle(color: AppTheme.textMuted)), + style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.textMuted.withValues(alpha: 0.5))), ), const SizedBox(width: 10), ], ElevatedButton.icon( - onPressed: () { - final dto = _buildDto(); - widget.onAccept(dto); - Navigator.pop(context); - }, + onPressed: _canSubmit + ? () { + final dto = _buildDto(); + widget.onAccept(dto); + Navigator.pop(context); + } + : null, icon: Icon(widget.isActive ? Icons.save : Icons.check_circle, size: 18), label: Text( - widget.isActive ? 'Einstellungen Speichern' : 'Trade Jetzt Eröffnen', + widget.isActive ? 'Ausführung Speichern' : 'Trade Jetzt Eröffnen', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), style: ElevatedButton.styleFrom( @@ -916,105 +318,27 @@ class _TradeExecutionCockpitState extends State { ); } - Widget _instrumentChip(String label, String value) { - final isSelected = _selectedInstrument == value; - return GestureDetector( - onTap: () => setState(() => _selectedInstrument = value), - child: Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : AppTheme.glassSurface, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder), - ), - child: Text( - label, - style: TextStyle( - color: isSelected ? AppTheme.primaryEmerald : Colors.white70, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - fontSize: 12, - ), - ), - ), - ); - } - - Widget _capitalPill(String label, double val) { - return GestureDetector( - onTap: () => setState(() => _positionSizeCtrl.text = val.toStringAsFixed(0)), - child: Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white12), - ), - child: Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold)), - ), - ); - } - - Widget _buildInput(String label, TextEditingController controller, IconData icon, {Color? accentColor, Widget? suffixWidget}) { + Widget _buildInput(String label, TextEditingController controller, IconData icon) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(color: accentColor ?? AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)), + Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)), const SizedBox(height: 6), TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), decoration: InputDecoration( - prefixIcon: Icon(icon, size: 16, color: accentColor ?? Colors.white54), - suffixIcon: suffixWidget, + prefixIcon: Icon(icon, size: 16, color: Colors.white54), filled: true, fillColor: AppTheme.glassSurface, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: accentColor ?? AppTheme.accentCyan)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.accentCyan)), ), ), ], ); } - - Widget _buildSmallFeeInput(String label, TextEditingController controller) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), - const SizedBox(height: 3), - SizedBox( - height: 32, - child: TextField( - controller: controller, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), - decoration: InputDecoration( - filled: true, - fillColor: AppTheme.glassSurface, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: AppTheme.glassBorder)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: AppTheme.glassBorder)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: AppTheme.accentCyan)), - ), - ), - ), - ], - ); - } - - Widget _statTile(String label, String value, Color col) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), - const SizedBox(height: 2), - Text(value, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)), - ], - ); - } } diff --git a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart deleted file mode 100644 index b8e8624..0000000 --- a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart +++ /dev/null @@ -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 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(); - 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 = >[]; - 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( - 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, - ), - ), - ], - ); - }, - ); - }, - ); - } -} \ No newline at end of file diff --git a/FinlyticApp/lib/features/trades/widgets/trade_performance_bar.dart b/FinlyticApp/lib/features/trades/widgets/trade_performance_bar.dart index ba90ca0..11cf33e 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_performance_bar.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_performance_bar.dart @@ -27,10 +27,11 @@ class TradePerformanceBar extends StatelessWidget { @override Widget build(BuildContext context) { - final totalOpenPnlAbs = activeTrades.fold(0, (sum, t) => sum + t.calculatedPnlAbs); + final totalOpenPnlAbs = activeTrades.fold(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( diff --git a/FinlyticApp/lib/shared/widgets/evaluation_score_breakdown_sheet.dart b/FinlyticApp/lib/shared/widgets/evaluation_score_breakdown_sheet.dart new file mode 100644 index 0000000..51c0fd1 --- /dev/null +++ b/FinlyticApp/lib/shared/widgets/evaluation_score_breakdown_sheet.dart @@ -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 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 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 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), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/shared/widgets/responsive_scaffold.dart b/FinlyticApp/lib/shared/widgets/responsive_scaffold.dart index 6aa5027..0eca753 100644 --- a/FinlyticApp/lib/shared/widgets/responsive_scaffold.dart +++ b/FinlyticApp/lib/shared/widgets/responsive_scaffold.dart @@ -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 { '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 { 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( @@ -122,8 +131,14 @@ class _ResponsiveScaffoldState extends State { 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'), ], ), ); diff --git a/FinlyticApp/pubspec.lock b/FinlyticApp/pubspec.lock index 07eaee6..1d729e7 100644 --- a/FinlyticApp/pubspec.lock +++ b/FinlyticApp/pubspec.lock @@ -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: diff --git a/FinlyticApp/pubspec.yaml b/FinlyticApp/pubspec.yaml index 0442d46..246c58f 100644 --- a/FinlyticApp/pubspec.yaml +++ b/FinlyticApp/pubspec.yaml @@ -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 diff --git a/FinlyticApp/third_party/signalr_core/CHANGELOG.md b/FinlyticApp/third_party/signalr_core/CHANGELOG.md new file mode 100644 index 0000000..45038d7 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/FinlyticApp/third_party/signalr_core/LICENSE b/FinlyticApp/third_party/signalr_core/LICENSE new file mode 100644 index 0000000..012fd7e --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/LICENSE @@ -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. diff --git a/FinlyticApp/third_party/signalr_core/README.md b/FinlyticApp/third_party/signalr_core/README.md new file mode 100644 index 0000000..d2b97fc --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/README.md @@ -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!']); +``` diff --git a/FinlyticApp/third_party/signalr_core/analysis_options.yaml b/FinlyticApp/third_party/signalr_core/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/FinlyticApp/third_party/signalr_core/example/main.dart b/FinlyticApp/third_party/signalr_core/example/main.dart new file mode 100644 index 0000000..cbea27c --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/example/main.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:http/io_client.dart'; +import 'package:signalr_core/signalr_core.dart'; + +Future main(List 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!']); +} diff --git a/FinlyticApp/third_party/signalr_core/lib/signalr_core.dart b/FinlyticApp/third_party/signalr_core/lib/signalr_core.dart new file mode 100644 index 0000000..ed3100b --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/signalr_core.dart @@ -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'; diff --git a/FinlyticApp/third_party/signalr_core/lib/src/connection.dart b/FinlyticApp/third_party/signalr_core/lib/src/connection.dart new file mode 100644 index 0000000..4349c42 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/connection.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 start({ + transfer.TransferFormat? transferFormat = transfer.TransferFormat.binary, + }); + + Future send(dynamic data); + + Future stop({Exception? exception}); +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/default_reconnect_policy.dart b/FinlyticApp/third_party/signalr_core/lib/src/default_reconnect_policy.dart new file mode 100644 index 0000000..fe5f46d --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/default_reconnect_policy.dart @@ -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 retryDelays; + + @override + int? nextRetryDelayInMilliseconds(RetryContext retryContext) { + return retryDelays[retryContext.previousRetryCount!]; + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/handshake_protocol.dart b/FinlyticApp/third_party/signalr_core/lib/src/handshake_protocol.dart new file mode 100644 index 0000000..4dfbde5 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/handshake_protocol.dart @@ -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 toJson() => { + 'protocol': protocol, + 'version': version, + }; +} + +extension HandshakeResponseMessageExtensions on HandshakeResponseMessage { + static HandshakeResponseMessage fromJson(Map 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 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); + + // if (response.type) { + // throw new Error("Expected a handshake response from the server."); + // } + + _responseMessage = response; + + return Tuple2( + _remainingData, + _responseMessage, + ); + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/http_connection.dart b/FinlyticApp/third_party/signalr_core/lib/src/http_connection.dart new file mode 100644 index 0000000..a9bac8b --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/http_connection.dart @@ -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? availableTransports; + final String? url; + final String? accessToken; + final String? error; +} + +extension NegotiateResponseExtensions on NegotiateResponse { + static NegotiateResponse fromJson(Map 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?, + ), + 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? transferFormats; +} + +extension AvailableTransportExtensions on AvailableTransport { + static AvailableTransport fromJson(Map json) { + return AvailableTransport( + transport: + HttpTransportTypeExtensions.fromName(json['transport'] as String?), + transferFormats: List.from( + json['transferFormats'] as Iterable) + .map((value) => TransferFormatExtensions.fromName(value as String)) + .toList(), + ); + } + + static List listFromJson(List? json) { + return json == null + ? [] + : json + .map((value) => AvailableTransportExtensions.fromJson( + value as Map)) + .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? _startInternalFuture; + Future? _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 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 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 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 _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 _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 _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.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); + + 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 _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: { + ...uri.queryParameters, + ...{'id': connectionToken}, + }, + ).toString(); + } + + Future _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 _buffer = []; + late Completer _sendBufferedData; + bool _executing = true; + Completer? _transportResult; + Future? _sendLoopPromise; + + final Transport? transport; + + TransportSendQueue({this.transport}) { + _sendBufferedData = Completer(); + _transportResult = Completer(); + + _sendLoopPromise = sendLoop(); + } + + Future send(dynamic data) { + _bufferData(data); + _transportResult ??= Completer(); + return _transportResult!.future; + } + + Future? 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 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); + + _buffer.clear(); + + try { + await transport!.send(data); + transportResult!.complete(); + } catch (error) { + transportResult!.completeError(error); + } + } + } + } + + static ByteBuffer _concatBuffers(List 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; + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/http_connection_options.dart b/FinlyticApp/third_party/signalr_core/lib/src/http_connection_options.dart new file mode 100644 index 0000000..4746d61 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/http_connection_options.dart @@ -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? 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; +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/hub_connection.dart b/FinlyticApp/third_party/signalr_core/lib/src/hub_connection.dart new file mode 100644 index 0000000..32f9e47 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/hub_connection.dart @@ -0,0 +1,1025 @@ +import 'dart:async'; + +import 'package:signalr_core/src/connection.dart'; +import 'package:signalr_core/src/handshake_protocol.dart'; +import 'package:signalr_core/src/hub_protocol.dart'; +import 'package:signalr_core/src/logger.dart'; +import 'package:signalr_core/src/retry_policy.dart'; +import 'package:signalr_core/src/utils.dart'; +import 'package:tuple/tuple.dart'; + +typedef InvocationEventCallback = void Function( + HubMessage? invocationEvent, Exception? exception); +typedef MethodInvocationFunc = void Function(List? arguments); +typedef ClosedCallback = void Function(Exception? exception); +typedef ReconnectingCallback = void Function(Exception? exception); +typedef ReconnectedCallback = void Function(String? connectionId); + +const int DEFAULT_TIMEOUT_IN_MS = 30 * 1000; +const int DEFAULT_PING_INTERVAL_IN_MS = 15 * 1000; + +/// Describes the current state of the [HubConnection] to the server. +enum HubConnectionState { + /// The hub connection is disconnected. + disconnected, + + /// The hub connection is connecting. + connecting, + + /// The hub connection is connected. + connected, + + /// The hub connection is disconnecting. + disconnecting, + + /// The hub connection is reconnecting. + reconnecting +} + +/// Represents a connection to a SignalR Hub. +class HubConnection { + final dynamic _cachedPingMessage; + final Connection? _connection; + final Logging? _logger; + final RetryPolicy? _reconnectPolicy; + final HubProtocol _protocol; + late HandshakeProtocol _handshakeProtocol; + late Map _callbacks; + late Map> _methods; + int? _invocationId; + + late List _closedCallbacks; + late List _reconnectingCallbacks; + late List _reconnectedCallbacks; + + bool? _receivedHandshakeResponse; + late Completer _handshakeCompleter; + Exception? _stopDuringStartError; + + HubConnectionState? _connectionState; + late bool _connectionStarted; + Future? _startFuture; + Future? _stopFuture; + + Timer? _reconnectDelayHandle; + Timer? _timeoutHandle; + Timer? _pingServerHandle; + + HubConnection({ + Connection? connection, + Logging? logging, + required HubProtocol protocol, + RetryPolicy? reconnectPolicy, + }) : _cachedPingMessage = protocol.writeMessage(PingMessage()), + _connection = connection, + _logger = logging, + _protocol = protocol, + _reconnectPolicy = reconnectPolicy { + serverTimeoutInMilliseconds = DEFAULT_TIMEOUT_IN_MS; + keepAliveIntervalInMilliseconds = DEFAULT_PING_INTERVAL_IN_MS; + + _handshakeProtocol = HandshakeProtocol(); + if (_connection != null) { + _connection + ..onreceive = _processIncomingData + ..onclose = + (Exception? exception) => _connectionClosed(exception: exception); + } + + _callbacks = {}; + _methods = {}; + _closedCallbacks = []; + _reconnectingCallbacks = []; + _reconnectedCallbacks = []; + _invocationId = 0; + _receivedHandshakeResponse = false; + _connectionState = HubConnectionState.disconnected; + _connectionStarted = false; + } + + /// The server timeout in milliseconds. + /// + /// If this timeout elapses without receiving any messages from the server, the connection will be terminated with an error. + /// The default timeout value is 30,000 milliseconds (30 seconds). + late int serverTimeoutInMilliseconds; + + /// Default interval at which to ping the server. + /// + /// The default value is 15,000 milliseconds (15 seconds). + /// Allows the server to detect hard disconnects (like when a client unplugs their computer). + late int keepAliveIntervalInMilliseconds; + + /// Indicates the state of the {@link HubConnection} to the server. + HubConnectionState? get state => _connectionState; + + /// Represents the connection id of the [HubConnection] on the server. The connection id will be null when the connection is either + /// in the disconnected state or if the negotiation step was skipped. + String? get connectionId => _connection!.connectionId; + + /// Indicates the url of the {@link HubConnection} to the server. + String get baseUrl => _connection!.baseUrl!; + + /// Sets a new url for the HubConnection. Note that the url can only be changed when the connection is in either the Disconnected or + /// Reconnecting states. + set baseUrl(String url) { + if ((_connectionState != HubConnectionState.disconnected) && + (_connectionState != HubConnectionState.reconnecting)) { + throw Exception( + 'The HubConnection must be in the Disconnected or Reconnecting state to change the url.'); + } + + if (url.isEmpty) { + throw Exception('The HubConnection url must be a valid url.'); + } + + _connection!.baseUrl = url; + } + + /// Starts the connection. + Future? start() { + _startFuture = _startWithStateTransitions(); + return _startFuture; + } + + Future _startWithStateTransitions() async { + if (_connectionState != HubConnectionState.disconnected) { + return Future.error(Exception( + 'Cannot start a HubConnection that is not in the \'Disconnected\' state.')); + } + + _connectionState = HubConnectionState.connecting; + _logger!(LogLevel.debug, 'Starting HubConnection.'); + + try { + await _startInternal(); + + _connectionState = HubConnectionState.connected; + _connectionStarted = true; + _logger(LogLevel.debug, 'HubConnection connected successfully.'); + } catch (e) { + _connectionState = HubConnectionState.disconnected; + _logger(LogLevel.debug, + 'HubConnection failed to start successfully because of error \'{$e.toString()}\'.'); + return Future.error(e); + } + } + + Future _startInternal() async { + _stopDuringStartError = null; + _receivedHandshakeResponse = false; + + _handshakeCompleter = Completer(); + final handshakeFuture = _handshakeCompleter.future; + + // Set up the promise before any connection is (re)started otherwise it could race with received messages + // final handshakeFuture = Future.value((resolve, reject) async => { + // _handshakeResolver = resolve, + // _handshakeRejector = reject + // }); + + await _connection!.start(transferFormat: _protocol.transferFormat); + + try { + final handshakeRequest = HandshakeRequestMessage( + protocol: _protocol.name, + version: _protocol.version, + ); + + _logger!(LogLevel.debug, 'Sending handshake request.'); + + await _sendMessage( + _handshakeProtocol.writeHandshakeRequest(handshakeRequest)); + + _logger(LogLevel.information, 'Using HubProtocol \'${_protocol.name}\'.'); + + // defensively cleanup timeout in case we receive a message from the server before we finish start + _cleanupTimeout(); + _resetTimeoutPeriod(); + _resetKeepAliveInterval(); + + await handshakeFuture; + + // It's important to check the stopDuringStartError instead of just relying on the handshakePromise + // being rejected on close, because this continuation can run after both the handshake completed successfully + // and the connection was closed. + if (_stopDuringStartError != null) { + // It's important to throw instead of returning a rejected promise, because we don't want to allow any state + // transitions to occur between now and the calling code observing the exceptions. Returning a rejected promise + // will cause the calling continuation to get scheduled to run later. + throw _stopDuringStartError!; + } + } catch (e) { + _logger!(LogLevel.debug, + 'Hub handshake failed with error \'${e.toString()}\' during start(). Stopping HubConnection.'); + + _cleanupTimeout(); + _cleanupPingTimer(); + + // HttpConnection.stop() should not complete until after the onclose callback is invoked. + // This will transition the HubConnection to the disconnected state before HttpConnection.stop() completes. + await _connection.stop(exception: e as Exception); + rethrow; + } + } + + /// Stops the connection. + Future stop() async { + // Capture the start future before the connection might be restarted in an onclose callback. + final startFuture = _startFuture; + + _stopFuture = _stopInternal(); + await _stopFuture; + + try { + // Awaiting undefined continues immediately + await startFuture; + } catch (e) { + // This exception is returned to the user as a rejected Future from the start method. + } + } + + Future? _stopInternal({Exception? exception}) async { + if (_connectionState == HubConnectionState.disconnected) { + _logger!(LogLevel.debug, + 'Call to HubConnection.stop(${exception.toString()}) ignored because it is already in the disconnected state.'); + return Future.value(null); + } + + if (_connectionState == HubConnectionState.disconnecting) { + _logger!(LogLevel.debug, + 'Call to HttpConnection.stop(${exception.toString()}) ignored because the connection is already in the disconnecting state.'); + return _stopFuture; + } + + _connectionState = HubConnectionState.disconnecting; + + _logger!(LogLevel.debug, 'Stopping HubConnection'); + + if (_reconnectDelayHandle != null) { + // We're in a reconnect delay which means the underlying connection is currently already stopped. + // Just clear the handle to stop the reconnect loop (which no one is waiting on thankfully) and + // fire the onclose callbacks. + _logger(LogLevel.debug, + 'Connection stopped during reconnect delay. Done reconnecting.'); + + _clearTimeout(_reconnectDelayHandle); + _reconnectDelayHandle = null; + + _completeClose(); + return Future.value(null); + } + + _cleanupTimeout(); + _cleanupPingTimer(); + _stopDuringStartError = (exception != null) + ? exception + : Exception( + 'The connection was stopped before the hub handshake could complete.'); + + // HttpConnection.stop() should not complete until after either HttpConnection.start() fails + // or the onclose callback is invoked. The onclose callback will transition the HubConnection + // to the disconnected state if need be before HttpConnection.stop() completes. + return _connection!.stop(exception: exception); + } + + /// Invokes a hub method on the server using the specified name and arguments. Does not wait for a response from the receiver. + /// + /// The Promise returned by this method resolves when the client has sent the invocation to the server. The server may still + /// be processing the invocation. + Future send({ + String? methodName, + List? args, + }) { + final streamParameters = _replaceStreamParameters(args); + final sendPromise = _sendWithProtocol( + _createInvocation( + methodName: methodName, + args: args, + nonblocking: true, + streamIds: streamParameters.item2, + ), + ); + + _launchStreams(streamParameters.item1, sendPromise); + + return sendPromise; + } + + Future _sendMessage(dynamic message) { + _resetKeepAliveInterval(); + return _connection!.send(message); + } + + Future _sendWithProtocol(dynamic message) { + return _sendMessage(_protocol.writeMessage(message as HubMessage)); + } + + void _resetKeepAliveInterval() { + // if (_connection.features.inherentKeepAlive) { + // return; + // } + + _cleanupPingTimer(); + _pingServerHandle = + Timer.periodic(Duration(milliseconds: keepAliveIntervalInMilliseconds), + (Timer timer) async { + if (_connectionState == HubConnectionState.connected) { + try { + await _sendMessage(_cachedPingMessage); + } catch (e) { + //We don't care about the error. It should be seen elsewhere in the client. + // The connection is probably in a bad or closed state now, cleanup the timer so it stops triggering + _cleanupPingTimer(); + } + } + }); + } + + void _resetTimeoutPeriod() { + if ((_connection!.features != null) || + (_connection.features.inherentKeepAlive == false)) { + // Set the timeout timer + _timeoutHandle = Timer.periodic( + Duration(milliseconds: serverTimeoutInMilliseconds), (Timer timer) { + _serverTimeout(); + timer.cancel(); + }); + } + } + + void _serverTimeout() { + // The server hasn't talked to us in a while. It doesn't like us anymore ... :( + // Terminate the connection, but we don't need to wait on the promise. This could trigger reconnecting. + // tslint:disable-next-line:no-floating-promises + _connection!.stop( + exception: Exception( + 'Server timeout elapsed without receiving a message from the server.'), + ); + } + + void _completeClose({Exception? exception}) { + if (_connectionStarted) { + _connectionState = HubConnectionState.disconnected; + _connectionStarted = false; + + try { + for (var callback in _closedCallbacks) { + callback(exception); + } + } catch (e) { + _logger!(LogLevel.error, + 'An onclose callback called with error \'${exception.toString()}\' threw error \'${e.toString()}\'.'); + } + } + } + + Future _reconnect({Exception? exception}) async { + final reconnectStartTime = Stopwatch()..start(); + //final reconnectStartTime = DateTime.now(); + var previousReconnectAttempts = 0; + var retryError = (exception != null) + ? exception + : Exception('Attempting to reconnect due to a unknown error.'); + + var nextRetryDelay = _getNextRetryDelay( + previousRetryCount: previousReconnectAttempts++, + elapsedMilliseconds: 0, + retryReason: retryError); + + if (nextRetryDelay == null) { + _logger!(LogLevel.debug, + 'Connection not reconnecting because the RetryPolicy returned null on the first reconnect attempt.'); + _completeClose(exception: exception); + return; + } + + _connectionState = HubConnectionState.reconnecting; + + if (exception != null) { + _logger!(LogLevel.information, + 'Connection reconnecting because of error \'${exception.toString()}\'.'); + } else { + _logger!(LogLevel.information, 'Connection reconnecting.'); + } + + try { + for (var callback in _reconnectingCallbacks) { + callback(exception); + } + } catch (e) { + _logger(LogLevel.error, + 'An onreconnecting callback called with error \'${exception.toString()}\' threw error \'${e.toString()}\'.'); + } + + // Exit early if an onreconnecting callback called connection.stop(). + if (_connectionState != HubConnectionState.reconnecting) { + _logger(LogLevel.debug, + 'Connection left the reconnecting state in onreconnecting callback. Done reconnecting.'); + return; + } + + while (nextRetryDelay != null) { + _logger(LogLevel.information, + 'Reconnect attempt number $previousReconnectAttempts will start in $nextRetryDelay ms.'); + + await Future(() { + var completer = Completer(); + _reconnectDelayHandle = Timer(Duration(milliseconds: nextRetryDelay!), + () => completer.complete()); + return completer.future; + }); + _reconnectDelayHandle = null; + + if (_connectionState == null || + _connectionState != HubConnectionState.reconnecting) { + _logger(LogLevel.debug, + 'Connection left the reconnecting state during reconnect delay. Done reconnecting.'); + return; + } + + try { + await _startInternal(); + + _connectionState = HubConnectionState.connected; + _logger( + LogLevel.information, 'HubConnection reconnected successfully.'); + + try { + for (var callback in _reconnectedCallbacks) { + callback(_connection!.connectionId); + } + } catch (e) { + _logger(LogLevel.error, + 'An onreconnected callback called with connectionId \'${_connection!.connectionId}; threw error \'${e.toString()}\'.'); + } + + return; + } catch (e) { + _logger(LogLevel.information, + 'Reconnect attempt failed because of error \'${e.toString()}\'.'); + + if (_connectionState != HubConnectionState.reconnecting) { + _logger(LogLevel.debug, + 'Connection left the reconnecting state during reconnect attempt. Done reconnecting.'); + return; + } + + retryError = (e is Exception) ? e : Exception(e.toString()); + nextRetryDelay = _getNextRetryDelay( + previousRetryCount: previousReconnectAttempts++, + elapsedMilliseconds: reconnectStartTime.elapsedMilliseconds, + retryReason: retryError, + ); + } + } + + _logger(LogLevel.information, + 'Reconnect retries have been exhausted after ${reconnectStartTime.elapsedMilliseconds} ms and $previousReconnectAttempts failed attempts. Connection disconnecting.'); + + _completeClose(); + } + + int? _getNextRetryDelay({ + int? previousRetryCount, + int? elapsedMilliseconds, + Exception? retryReason, + }) { + try { + return _reconnectPolicy!.nextRetryDelayInMilliseconds( + RetryContext( + elapsedMilliseconds: elapsedMilliseconds, + previousRetryCount: previousRetryCount, + retryReason: retryReason, + ), + ); + } catch (e) { + _logger!(LogLevel.error, + 'RetryPolicy.nextRetryDelayInMilliseconds($previousRetryCount, $elapsedMilliseconds) threw error \'${e.toString()}\'.'); + return null; + } + } + + /// Invokes a streaming hub method on the server using the specified name and arguments. + Stream stream(String methodName, {List? args}) { + final streamParameters = _replaceStreamParameters(args); + final invocationDescriptor = _createStreamInvocation( + methodName: methodName, args: args, streamIds: streamParameters.item2); + + late Future futureQueue; + final controller = StreamController() + ..onCancel = () { + final cancelInvocation = + _createCancelInvocation(id: invocationDescriptor.invocationId); + + _callbacks.remove(invocationDescriptor.invocationId); + + futureQueue.then((value) { + return _sendWithProtocol(cancelInvocation); + }); + }; + + _callbacks[invocationDescriptor.invocationId] = + (HubMessage? invocationEvent, Exception? error) { + if (error != null) { + controller.addError(error); + return; + } else if (invocationEvent != null) { + if (invocationEvent.type == MessageType.completion) { + if (invocationEvent is CompletionMessage) { + if (invocationEvent.error != null) { + if (invocationEvent.error!.isNotEmpty) { + controller.addError(Exception(error)); + } else { + controller.close(); + } + } else { + controller.close(); + } + } + } else { + if (invocationEvent is StreamItemMessage) { + controller.add(invocationEvent.item as T?); + } + } + } + }; + + futureQueue = _sendWithProtocol(invocationDescriptor).catchError((e) { + if (e is Object) { + controller.addError(e); + } + _callbacks.remove(invocationDescriptor.invocationId); + }); + + _launchStreams(streamParameters.item1, futureQueue); + + return controller.stream; + } + + /// Invokes a hub method on the server using the specified name and arguments. + /// + /// The Future returned by this method resolves when the server indicates it has finished invoking the method. When the future + /// resolves, the server has finished invoking the method. If the server method returns a result, it is produced as the result of + /// resolving the Future. + Future invoke(String methodName, {List? args}) { + final streamParameters = _replaceStreamParameters(args); + final invocationDescriptor = _createInvocation( + methodName: methodName, + args: args, + nonblocking: false, + streamIds: streamParameters.item2, + ); + + final p = Completer(); + + _callbacks[invocationDescriptor.invocationId] = + (HubMessage? invocationEvent, Exception? error) { + if (error != null) { + p.completeError(error); + } else if (invocationEvent != null) { + // invocationEvent will not be null when an error is not passed to the callback + if (invocationEvent.type == MessageType.completion) { + if (invocationEvent is CompletionMessage) { + if (invocationEvent.error != null) { + p.completeError(Exception(invocationEvent.error)); + } else { + p.complete(invocationEvent.result); + } + } else { + p.completeError( + Exception('Unexpected message type: ${invocationEvent.type}')); + } + } + } + }; + + final futureQueue = _sendWithProtocol(invocationDescriptor).catchError((e) { + if (e is Object) { + p.completeError(e); + } + _callbacks.remove(invocationDescriptor.invocationId); + }); + + _launchStreams(streamParameters.item1, futureQueue); + + return p.future; + } + + Tuple2>, List> _replaceStreamParameters( + List? args) { + final streams = >{}; + final streamIds = []; + + if (args != null) { + for (var i = 0; i < args.length; i++) { + final argument = args[i]; + if (argument is Stream) { + final streamId = _invocationId; + if (_invocationId != null) { + _invocationId = _invocationId! + 1; + } + // Store the stream for later use + streams[streamId] = argument; + streamIds.add(streamId.toString()); + + // remove stream from args + args.removeAt(i); + } + } + } + + return Tuple2>, List>(streams, streamIds); + } + + /// Registers a handler that will be invoked when the hub method with the specified method name is invoked. + void on(String methodName, MethodInvocationFunc newMethod) { + if (methodName.isEmpty) { + return; + } + + methodName = methodName.toLowerCase(); + if (_methods[methodName] == null) { + _methods[methodName] = []; + } + + // Preventing adding the same handler multiple times. + if (_methods[methodName]!.contains(newMethod)) { + return; + } + + _methods[methodName]!.add(newMethod); + } + + /// Removes all handlers for the specified hub method. + void off(String methodName, {MethodInvocationFunc? method}) { + if (methodName.isEmpty) { + return; + } + + methodName = methodName.toLowerCase(); + final List)>? handlers = _methods[methodName]; + if (handlers == null) { + return; + } + + if (method != null) { + final removeIdx = handlers.indexOf(method); + if (removeIdx != -1) { + handlers.removeAt(removeIdx); + if (handlers.isEmpty) { + _methods.remove(methodName); + } + } + } else { + _methods.remove(methodName); + } + } + + void _processIncomingData(dynamic data) { + _cleanupTimeout(); + + if (_receivedHandshakeResponse == false) { + data = _processHandshakeResponse(data); + _receivedHandshakeResponse = true; + } + + // Data may have all been read when processing handshake response + if (data != null && data is Object) { + // Parse the messages + final messages = _protocol.parseMessages(data, _logger); + + for (final message in messages) { + switch (message!.type) { + case MessageType.undefined: + break; + case MessageType.invocation: + _invokeClientMethod(message as InvocationMessage); + break; + case MessageType.streamItem: + case MessageType.completion: + var invocationMessage = message as HubInvocationMessage; + final void Function(HubMessage, Exception?)? callback = + _callbacks[invocationMessage.invocationId]; + + if (callback != null) { + if (message.type == MessageType.completion) { + _callbacks.remove(invocationMessage.invocationId); + } + callback(message, null); + } + + break; + case MessageType.streamInvocation: + break; + case MessageType.cancelInvocation: + break; + case MessageType.ping: + break; + case MessageType.close: + final closeMessage = message as CloseMessage; + _logger!( + LogLevel.information, 'Close message received from server.'); + + final exception = (closeMessage.error != null) + ? Exception( + 'Server returned an error on close: ' + closeMessage.error!) + : null; + + if (closeMessage.allowReconnect == true) { + // It feels wrong not to await connection.stop() here, but processIncomingData is called as part of an onreceive callback which is not async, + // this is already the behavior for serverTimeout(), and HttpConnection.Stop() should catch and log all possible exceptions. + + _connection!.stop(exception: exception); + } else { + // We cannot await stopInternal() here, but subsequent calls to stop() will await this if stopInternal() is still ongoing. + _stopFuture = _stopInternal(exception: exception); + } + break; + default: + _logger!( + LogLevel.warning, 'Invalid message type: ${message.type}.'); + break; + } + } + } + + _resetTimeoutPeriod(); + } + + dynamic _processHandshakeResponse(dynamic data) { + HandshakeResponseMessage responseMessage; + dynamic remainingData; + + try { + var response = _handshakeProtocol.parseHandshakeResponse(data); + remainingData = response.item1; + responseMessage = response.item2; + } catch (e) { + final message = 'Error parsing handshake response: ' + e.toString(); + _logger!(LogLevel.error, message); + + final exception = Exception(message); + _handshakeCompleter.completeError(exception); + throw exception; + } + + if (responseMessage.error != null) { + final message = + 'Server returned handshake error: ' + responseMessage.error!; + _logger!(LogLevel.error, message); + + final exception = Exception(message); + _handshakeCompleter.completeError(exception); + throw exception; + } else { + _logger!(LogLevel.debug, 'Server handshake complete.'); + } + + _handshakeCompleter.complete(); + return remainingData; + } + + void _connectionClosed({Exception? exception}) { + _logger!(LogLevel.debug, + 'HubConnection.connectionClosed(${exception.toString()}) called while in state ${_connectionState.toString()}.'); + + // Triggering this.handshakeRejecter is insufficient because it could already be resolved without the continuation having run yet. + _stopDuringStartError ??= (exception != null) + ? exception + : Exception( + 'The underlying connection was closed before the hub handshake could complete.'); + + // If the handshake is in progress, start will be waiting for the handshake future, so we complete it. + // If it has already completed, this should just noop. + if (!_handshakeCompleter.isCompleted) { + _handshakeCompleter.complete(); + } + + _cancelCallbacksWithError( + (exception != null) + ? exception + : Exception( + 'Invocation canceled due to the underlying connection being closed.'), + ); + + _cleanupTimeout(); + _cleanupPingTimer(); + + if (_connectionState == HubConnectionState.disconnecting) { + _completeClose(exception: exception); + } else if ((_connectionState == HubConnectionState.connected) && + _reconnectPolicy != null) { + _reconnect(exception: exception); + } else if (_connectionState == HubConnectionState.connected) { + _completeClose(exception: exception); + } + + // If none of the above if conditions were true were called the HubConnection must be in either: + // 1. The Connecting state in which case the handshakeResolver will complete it and stopDuringStartError will fail it. + // 2. The Reconnecting state in which case the handshakeResolver will complete it and stopDuringStartError will fail the current reconnect attempt + // and potentially continue the reconnect() loop. + // 3. The Disconnected state in which case we're already done. + } + + void _cancelCallbacksWithError(Exception exception) { + final Map callbacks = + _callbacks; + _callbacks = {}; + + callbacks.forEach((key, value) { + value(null, exception); + }); + } + + InvocationMessage _createInvocation({ + String? methodName, + List? args, + required bool nonblocking, + List? streamIds, + }) { + if (nonblocking) { + if (streamIds!.isNotEmpty) { + return InvocationMessage( + arguments: args, + streamIds: streamIds, + target: methodName, + ); + } else { + return InvocationMessage( + arguments: args, + streamIds: [], + target: methodName, + ); + } + } else { + final invocationId = _invocationId; + if (_invocationId != null) { + _invocationId = _invocationId! + 1; + } + + if (streamIds!.isNotEmpty) { + return InvocationMessage( + arguments: args, + invocationId: invocationId.toString(), + streamIds: streamIds, + target: methodName, + ); + } else { + return InvocationMessage( + arguments: args, + invocationId: invocationId.toString(), + streamIds: [], + target: methodName, + ); + } + } + } + + StreamInvocationMessage _createStreamInvocation({ + String? methodName, + List? args, + required List streamIds, + }) { + final invocationId = _invocationId; + if (_invocationId != null) { + _invocationId = _invocationId! + 1; + } + + if (streamIds.isNotEmpty) { + return StreamInvocationMessage( + arguments: args, + invocationId: invocationId.toString(), + streamIds: streamIds, + target: methodName, + ); + } else { + return StreamInvocationMessage( + arguments: args, + invocationId: invocationId.toString(), + streamIds: [], + target: methodName, + ); + } + } + + CancelInvocationMessage _createCancelInvocation({String? id}) { + return CancelInvocationMessage(invocationId: id); + } + + StreamItemMessage _createStreamItemMessage({String? id, dynamic item}) { + return StreamItemMessage(invocationId: id, item: item); + } + + CompletionMessage _createCompletionMessage({ + String? id, + Exception? exception, + dynamic result, + }) { + if (exception != null) { + return CompletionMessage( + error: exception.toString(), + invocationId: id, + ); + } + + return CompletionMessage(invocationId: id, result: result); + } + + void _launchStreams(Map streams, Future futureQueue) { + if (streams.isEmpty) { + return; + } + + futureQueue = Completer().future; + + streams.forEach((streamId, stream) { + stream.listen((data) { + futureQueue = futureQueue.then( + (_) => _sendWithProtocol( + _createStreamItemMessage( + id: streamId.toString(), + item: data, + ), + ), + ); + }, onDone: () { + futureQueue = futureQueue.then( + (_) => _sendWithProtocol( + _createCompletionMessage( + id: streamId.toString(), + ), + ), + ); + }, onError: (e) { + futureQueue = futureQueue.then( + (_) => _sendWithProtocol( + _createCompletionMessage( + id: streamId.toString(), + exception: e as Exception, + ), + ), + ); + }); + }); + } + + /// Registers a handler that will be invoked when the connection is closed. + void onclose(ClosedCallback callback) { + _closedCallbacks.add(callback); + } + + /// Registers a handler that will be invoked when the connection starts reconnecting. + void onreconnecting(ReconnectingCallback callback) { + _reconnectingCallbacks.add(callback); + } + + /// Registers a handler that will be invoked when the connection successfully reconnects. + void onreconnected(ReconnectedCallback callback) { + _reconnectedCallbacks.add(callback); + } + + void _cleanupPingTimer() { + if (_pingServerHandle != null) { + _clearTimeout(_pingServerHandle); + } + } + + void _cleanupTimeout() { + if (_timeoutHandle != null) { + _clearTimeout(_timeoutHandle); + } + } + + void _clearTimeout(Timer? timer) { + timer?.cancel(); + timer = null; + } + + void _invokeClientMethod(InvocationMessage invocationMessage) { + final methods = _methods[invocationMessage.target!.toLowerCase()]; + + if (methods != null) { + try { + for (var method in methods) { + method(invocationMessage.arguments); + } + } catch (e) { + _logger!(LogLevel.error, + 'A callback for the method ${invocationMessage.target!.toLowerCase()} threw error \'${e.toString()}\'.'); + } + + if (invocationMessage.invocationId != null) { + // This is not supported in v1. So we return an error to avoid blocking the server waiting for the response. + final message = + 'Server requested a response, which is not supported in this version of the client.'; + _logger!(LogLevel.error, message); + + // We don't want to wait on the stop itself. + _stopFuture = _stopInternal(exception: Exception(message)); + } + } else { + _logger!(LogLevel.warning, + 'No client method with the name \'${invocationMessage.target}\' found.'); + } + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/hub_connection_builder.dart b/FinlyticApp/third_party/signalr_core/lib/src/hub_connection_builder.dart new file mode 100644 index 0000000..26677d8 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/hub_connection_builder.dart @@ -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, + ); + } 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, + ); + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/hub_protocol.dart b/FinlyticApp/third_party/signalr_core/lib/src/hub_protocol.dart new file mode 100644 index 0000000..786a446 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/hub_protocol.dart @@ -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? 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? headers, + String? invocationId}) + : super( + type: MessageType.invocation, + headers: headers, + invocationId: invocationId); + + /// The target method name. + final String? target; + + /// The target method arguments. + final List? arguments; + + /// The target method stream IDs. + final List? streamIds; +} + +/// A hub message representing a streaming invocation. +class StreamInvocationMessage extends HubInvocationMessage { + StreamInvocationMessage( + {this.target, + this.arguments, + this.streamIds, + Map? headers, + String? invocationId}) + : super( + type: MessageType.streamInvocation, + headers: headers, + invocationId: invocationId); + + /// The target method name. + final String? target; + + /// The target method arguments. + final List? arguments; + + /// The target method stream IDs. + final List? streamIds; +} + +/// A hub message representing a single item produced as part of a result stream. +class StreamItemMessage extends HubInvocationMessage { + StreamItemMessage( + {this.item, Map? 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? 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 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 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? 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 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); +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/json_hub_protocol.dart b/FinlyticApp/third_party/signalr_core/lib/src/json_hub_protocol.dart new file mode 100644 index 0000000..1c74acf --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/json_hub_protocol.dart @@ -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 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 = []; + + // 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); + 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 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 json) { + return InvocationMessage( + target: json['target'] as String?, + arguments: json['arguments'] as List?, + headers: json['headers'] as Map?, + invocationId: json['invocationId'] as String?, + streamIds: json['streamIds'] as List?, + ); + } + + Map toJson() { + return { + 'type': type.value, + if (invocationId != null) 'invocationId': invocationId, + 'target': target, + 'arguments': arguments ?? [], + if (streamIds != null) 'streamIds': streamIds + }; + } +} + +extension StreamInvocationMessageExtensions on StreamInvocationMessage { + Map toJson() { + return { + 'type': type.value, + 'invocationId': invocationId, + 'target': target, + 'arguments': arguments, + 'streamIds': streamIds + }; + } +} + +extension StreamItemMessageExtensions on StreamItemMessage { + static StreamItemMessage fromJson(Map json) { + return StreamItemMessage( + item: json['item'] as dynamic, + headers: json['headers'] as Map?, + invocationId: json['invocationId'] as String?, + ); + } + + Map toJson() { + return { + 'type': type.value, + 'item': item, + 'invocationId': invocationId, + }; + } +} + +extension CancelInvocationMessageExtensions on CancelInvocationMessage { + Map toJson() { + return { + 'type': type.value, + 'invocationId': invocationId, + }; + } +} + +extension CompletionMessageExtensions on CompletionMessage { + static CompletionMessage fromJson(Map json) { + return CompletionMessage( + result: json['result'], + error: json['error'] as String?, + headers: json['headers'] as Map?, + invocationId: json['invocationId'] as String?, + ); + } + + Map toJson() { + return { + 'type': type.value, + 'invocationId': invocationId, + 'result': result, + 'error': error, + }; + } +} + +extension PingMessageExtensions on PingMessage { + static PingMessage fromJson(Map json) { + return PingMessage(); + } + + Map toJson() { + return { + 'type': type.value, + }; + } +} + +extension CloseMessageExtensions on CloseMessage { + static CloseMessage fromJson(Map json) { + return CloseMessage(error: json['error'] as String?); + } + + Map toJson() { + return { + 'type': type.value, + 'error': error, + }; + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/logger.dart b/FinlyticApp/third_party/signalr_core/lib/src/logger.dart new file mode 100644 index 0000000..17c05f2 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/logger.dart @@ -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, +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/retry_policy.dart b/FinlyticApp/third_party/signalr_core/lib/src/retry_policy.dart new file mode 100644 index 0000000..ea3a171 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/retry_policy.dart @@ -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; +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/text_message_format.dart b/FinlyticApp/third_party/signalr_core/lib/src/text_message_format.dart new file mode 100644 index 0000000..5b8e0a0 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/text_message_format.dart @@ -0,0 +1,23 @@ +mixin TextMessageFormat { + static const RecordSeparatorCode = 0x1e; + + static String recordSeparator = + String.fromCharCode(TextMessageFormat.RecordSeparatorCode); + + static String write(String output) { + return '$output${TextMessageFormat.recordSeparator}'; + } + + static List parse(String input) { + if (input.isEmpty) { + throw Exception('Message is incomplete.'); + } + + if (input[input.length - 1] != TextMessageFormat.recordSeparator) { + throw Exception('Message is incomplete.'); + } + + var messages = input.split(TextMessageFormat.recordSeparator)..removeLast(); + return messages; + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transport.dart b/FinlyticApp/third_party/signalr_core/lib/src/transport.dart new file mode 100644 index 0000000..7cba709 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transport.dart @@ -0,0 +1,76 @@ +import 'package:signalr_core/signalr_core.dart'; + +/// Specifies a specific HTTP transport type. +/// +/// This will be treated as a bit flag in the future, so we keep it using power-of-two values. +enum HttpTransportType { + /// Specifies no transport preference. + none, // 0 + /// Specifies the WebSockets transport. + webSockets, // 1 + /// Specifies the Server-Sent Events transport. + serverSentEvents, // 2 + /// Specifies the Long Polling transport. + longPolling // 4 +} + +extension HttpTransportTypeExtensions on HttpTransportType { + static HttpTransportType fromName(String? name) { + switch (name) { + case 'none': + { + return HttpTransportType.none; + } + case 'WebSockets': + { + return HttpTransportType.webSockets; + } + case 'ServerSentEvents': + { + return HttpTransportType.serverSentEvents; + } + case 'LongPolling': + { + return HttpTransportType.longPolling; + } + default: + return HttpTransportType.none; + } + } +} + +/// Specifies the transfer format for a connection. +enum TransferFormat { + /// Specifies that only text data will be transmitted over the connection. + text, // = 1, + /// Specifies that binary data will be transmitted over the connection. + binary, // = 2, +} + +extension TransferFormatExtensions on TransferFormat { + static TransferFormat fromName(String name) { + switch (name) { + case 'Text': + { + return TransferFormat.text; + } + case 'Binary': + { + return TransferFormat.binary; + } + default: + return TransferFormat.binary; + } + } +} + +/// An abstraction over the behavior of transports. +/// +/// This is designed to support the framework and not intended for use by applications. +abstract class Transport { + Future connect(String? url, TransferFormat? transferFormat); + Future send(dynamic data); + Future stop(); + OnReceive? onreceive; + OnClose? onclose; +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transports/long_polling_transport.dart b/FinlyticApp/third_party/signalr_core/lib/src/transports/long_polling_transport.dart new file mode 100644 index 0000000..7fd6f5c --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transports/long_polling_transport.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:http/http.dart'; +import 'package:signalr_core/signalr_core.dart'; + +class LongPollingTransport implements Transport { + final BaseClient? _client; + final AccessTokenFactory? _accessTokenFactory; + final Logging? _log; + final bool? _logMessageContent; + final bool? _withCredentials; + + String? _url; + late bool _running; + Future? _receiving; + Exception? _closeError; + + LongPollingTransport({ + BaseClient? client, + AccessTokenFactory? accessTokenFactory, + Logging? log, + bool? logMessageContent, + bool? withCredentials, + }) : _client = client, + _accessTokenFactory = accessTokenFactory, + _log = log, + _logMessageContent = logMessageContent, + _withCredentials = withCredentials { + _running = false; + onreceive = null; + onclose = null; + } + + @override + OnClose? onclose; + + @override + OnReceive? onreceive; + + @override + Future connect(String? url, TransferFormat? transferFormat) async { + _url = url; + + _log?.call(LogLevel.trace, '(LongPolling transport) Connecting.'); + + final headers = {}; + final userAgentHeader = getUserAgentHeader(); + headers[userAgentHeader.item1] = userAgentHeader.item2; + + final token = await _getAccessToken(); + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + // Make initial long polling request + // Server uses first long polling request to finish initializing connection and it returns without data + final pollUrl = '$url&_=${DateTime.now().millisecondsSinceEpoch}'; + _log?.call(LogLevel.trace, '(LongPolling transport) polling: $pollUrl.'); + final response = await _client!.get(Uri.parse(pollUrl), headers: headers); + if (response.statusCode != 200) { + _log?.call(LogLevel.error, + '(LongPolling transport) Unexpected response code: ${response.statusCode}.'); + + // Mark running as false so that the poll immediately ends and runs the close logic + _closeError = Exception(response.statusCode); + _running = false; + } else { + _running = true; + } + + _receiving = _poll(_url, headers); + } + + Future _getAccessToken() async { + if (_accessTokenFactory != null) { + return await _accessTokenFactory(); + } + + return null; + } + + Future _poll(String? url, Map headers) async { + try { + while (_running) { + // We have to get the access token on each poll, in case it changes + final token = await _getAccessToken(); + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final pollUrl = '$url&_=${DateTime.now().millisecondsSinceEpoch}'; + _log?.call( + LogLevel.trace, '(LongPolling transport) polling: $pollUrl.'); + final response = + await _client!.get(Uri.parse(pollUrl), headers: headers).timeout( + const Duration(milliseconds: 100000), + onTimeout: () { + _log?.call(LogLevel.warning, 'Timeout from HTTP request.'); + throw TimeoutException('A timeout occurred.'); + }, + ); + + if (response.statusCode == 204) { + _log?.call(LogLevel.information, + '(LongPolling transport) Poll terminated by server.'); + + _running = false; + } else if (response.statusCode != 200) { + _log?.call(LogLevel.error, + '(LongPolling transport) Unexpected response code: ${response.statusCode}.'); + + // Unexpected status code + _closeError = Exception(response.statusCode); + _running = false; + } else { + // Process the response + if (response.body.isNotEmpty) { + _log?.call(LogLevel.trace, + '(LongPolling transport) data received. ${getDataDetail(response.body, _logMessageContent)}.'); + + if (onreceive != null) { + onreceive!(response.body); + } + } else { + // This is another way timeout manifest. + _log?.call(LogLevel.trace, + '(LongPolling transport) Poll timed out, reissuing.'); + } + } + } + } catch (e) { + if (!_running) { + // Log but disregard errors that occur after stopping + _log?.call(LogLevel.trace, + '(LongPolling transport) Poll errored after shutdown: $e'); + } else { + if (e is TimeoutException) { + // Ignore timeouts and reissue the poll. + _log?.call(LogLevel.trace, + '(LongPolling transport) Poll timed out, reissuing.'); + } else { + // Close the connection with the error as the result. + _closeError = e as Exception; + _running = false; + } + } + } finally { + _log?.call(LogLevel.trace, '(LongPolling transport) Polling complete.'); + + // We will reach here with pollAborted==false when the server returned a response causing the transport to stop. + // If pollAborted==true then client initiated the stop and the stop method will raise the close event after DELETE is sent. + // if (_pollAborted) { + // _raiseOnClose(); + // } + } + } + + @override + Future send(data) async { + if (!_running) { + return Future.error( + Exception('Cannot send until the transport is connected')); + } + return sendMessage( + _log, + 'LongPolling', + _client, + _url, + _accessTokenFactory, + data, + _logMessageContent, + _withCredentials, + ); + } + + @override + Future stop() async { + _log?.call(LogLevel.trace, '(LongPolling transport) Stopping polling.'); + + // Tell receiving loop to stop, abort any current request, and then wait for it to finish + _running = false; + //_pollAbort.abort(); + + try { + await _receiving; + + // Send DELETE to clean up long polling on the server + _log?.call(LogLevel.trace, + '(LongPolling transport) sending DELETE request to $_url.'); + + final headers = {}; + final userAgentHeader = getUserAgentHeader(); + headers[userAgentHeader.item1] = userAgentHeader.item2; + + final token = await _getAccessToken(); + if (token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + await _client!.delete(Uri.parse(_url!), headers: headers); + + _log?.call( + LogLevel.trace, '(LongPolling transport) DELETE request sent.'); + } finally { + _log?.call(LogLevel.trace, '(LongPolling transport) Stop finished.'); + + // Raise close event here instead of in polling + // It needs to happen after the DELETE request is sent + _raiseOnClose(); + } + } + + void _raiseOnClose() { + if (onclose != null) { + var logMessage = '(LongPolling transport) Firing onclose event.'; + if (_closeError != null) { + logMessage += ' Error: ' + _closeError.toString(); + } + _log?.call(LogLevel.trace, logMessage); + onclose!(_closeError); + } + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transports/server_sent_events_transport.dart b/FinlyticApp/third_party/signalr_core/lib/src/transports/server_sent_events_transport.dart new file mode 100644 index 0000000..ae2963d --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transports/server_sent_events_transport.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:http/http.dart'; +import 'package:signalr_core/src/logger.dart'; +import 'package:signalr_core/src/transport.dart'; +import 'package:signalr_core/src/utils.dart'; +import 'package:sse_channel/sse_channel.dart'; + +class ServerSentEventsTransport implements Transport { + final BaseClient? _client; + final AccessTokenFactory? _accessTokenFactory; + final Logging? _log; + final bool? _logMessageContent; + final bool? _withCredentials; + String? _url; + SseChannel? _sseChannel; + + ServerSentEventsTransport({ + BaseClient? client, + AccessTokenFactory? accessTokenFactory, + Logging? logging, + bool? logMessageContent, + bool? withCredentials, + }) : _client = client, + _accessTokenFactory = accessTokenFactory, + _log = logging, + _logMessageContent = logMessageContent, + _withCredentials = withCredentials { + onclose = null; + onreceive = null; + } + + @override + OnClose? onclose; + + @override + OnReceive? onreceive; + + @override + Future connect(String? url, TransferFormat? transferFormat) async { + _log!(LogLevel.trace, '(SSE transport) Connecting.'); + + // set url before accessTokenFactory because this.url is only for send and we set the auth header instead of the query string for send + _url = url; + + if (_accessTokenFactory != null) { + final token = await _accessTokenFactory(); + if (token != null && _url != null) { + _url = _url! + + (!url!.contains('?') ? '?' : '&') + + 'access_token=${Uri.encodeComponent(token)}'; + } + } + + var completer = Completer(); + + var opened = false; + if (transferFormat != TransferFormat.text) { + return completer.completeError( + Exception( + 'The Server-Sent Events transport only supports the \'Text\' transfer format'), + ); + } + + SseChannel channel; + try { + channel = SseChannel.connect(Uri.parse(url!)); + _log(LogLevel.information, 'SSE connected to $_url'); + opened = true; + _sseChannel = channel; + completer.complete(); + } catch (e) { + return completer.completeError(e); + } + + _sseChannel!.stream.listen((data) { + _log(LogLevel.trace, + '(SSE transport) data received. ${getDataDetail(data, _logMessageContent)}'); + onreceive!(data); + }, onError: (e) { + if (opened) { + _close(exception: e as Exception); + } else if (e is Object) { + completer.completeError(e); + } + }); + + return completer.future; + } + + @override + Future send(data) async { + if (_sseChannel == null) { + return Future.error( + Exception('Cannot send until the transport is connected')); + } + return sendMessage( + _log!, + 'SSE', + _client!, + _url!, + _accessTokenFactory, + data, + _logMessageContent, + _withCredentials, + ); + } + + @override + Future stop() { + _close(); + return Future.value(null); + } + + void _close({Exception? exception}) { + if (_sseChannel != null) { + _sseChannel = null; + + if (onclose != null) { + onclose!(exception); + } + } + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_api.dart b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_api.dart new file mode 100644 index 0000000..a208a08 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_api.dart @@ -0,0 +1,5 @@ +import 'package:http/http.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +Future connect(Uri uri, {BaseClient? client}) => Future.error( + UnsupportedError('No implementation of the connect api provided')); diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_html.dart b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_html.dart new file mode 100644 index 0000000..585f037 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_html.dart @@ -0,0 +1,5 @@ +import 'package:http/http.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +Future connect(Uri uri, {BaseClient? client}) async => + Future.value(WebSocketChannel.connect(uri)); diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_io.dart b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_io.dart new file mode 100644 index 0000000..bcaa326 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_channel_io.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +Future connect( + Uri uri, { + required BaseClient client, +}) async { + var random = Random(); + + var nonceData = Uint8List(16); + for (var i = 0; i < 16; i++) { + nonceData[i] = random.nextInt(256); + } + var nonce = base64.encode(nonceData); + + WebSocket ws; + + var wsUri = Uri( + scheme: uri.scheme == 'wss' ? 'https' : 'http', + userInfo: uri.userInfo, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment); + + var request = Request('GET', wsUri) + ..headers.addAll({ + 'Connection': 'Upgrade', + 'Upgrade': 'websocket', + 'Sec-WebSocket-Key': nonce, + 'Cache-Control': 'no-cache', + 'Sec-WebSocket-Version': '13', + }); + + var response = await client.send(request); + var socket = await (response as IOStreamedResponse).detachSocket(); + + ws = WebSocket.fromUpgradedSocket( + socket, + serverSide: false, + ); + + return IOWebSocketChannel(ws); +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_transport.dart b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_transport.dart new file mode 100644 index 0000000..8e83420 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/transports/web_socket_transport.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:http/http.dart'; +import 'package:signalr_core/src/logger.dart'; +import 'package:signalr_core/src/transport.dart'; +import 'package:signalr_core/src/utils.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'web_socket_channel_api.dart' + // ignore: uri_does_not_exist + if (dart.library.io) 'web_socket_channel_io.dart' + // dart.library.html is only true under dart2js; dart.library.js_interop is true + // under both dart2js and dart2wasm, so this also selects the working browser + // implementation when compiled with `flutter build web --wasm`. + // ignore: uri_does_not_exist + if (dart.library.js_interop) 'web_socket_channel_html.dart' as platform; + +class WebSocketTransport implements Transport { + final Logging? _logging; + final AccessTokenFactory? _accessTokenFactory; + final bool? _logMessageContent; + final BaseClient? _client; + + StreamSubscription? _streamSubscription; + WebSocketChannel? _channel; + + WebSocketTransport({ + BaseClient? client, + AccessTokenFactory? accessTokenFactory, + Logging? logging, + bool? logMessageContent, + }) : _logging = logging, + _accessTokenFactory = accessTokenFactory, + _logMessageContent = logMessageContent, + _client = client { + onreceive = null; + onclose = null; + } + + @override + OnClose? onclose; + + @override + OnReceive? onreceive; + + @override + Future connect(String? url, TransferFormat? transferFormat) async { + assert(url != null); + assert(transferFormat != null); + + _logging!(LogLevel.trace, '(WebSockets transport) Connecting.'); + + if (_accessTokenFactory != null) { + final token = await _accessTokenFactory(); + if (token!.isNotEmpty) { + final encodedToken = Uri.encodeComponent(token); + url = url! + + (url.contains('?') ? '&' : '?') + + 'access_token=$encodedToken'; + } + } + + final connectFuture = Completer(); + var opened = false; + + url = url!.replaceFirst(RegExp(r'^http'), 'ws'); + + _channel = await platform.connect(Uri.parse(url), client: _client!); + + _logging(LogLevel.information, 'WebSocket connected to $url.'); + opened = true; + + _streamSubscription = _channel?.stream.listen((data) { + var dataDetail = getDataDetail(data, _logMessageContent); + _logging( + LogLevel.trace, '(WebSockets transport) data received. $dataDetail'); + if (onreceive != null) { + try { + onreceive!(data); + } on Exception catch (e1) { + _close(e1); + return; + } + } + }, onError: (e) { + _logging(LogLevel.error, + '(WebSockets transport) socket error: ${e.toString()}}'); + }, onDone: () { + if (opened == true) { + _close(null); + } else {} + }, cancelOnError: false); + + return connectFuture.complete(); + } + + @override + Future send(dynamic data) { + if ((_channel == null) || (_channel?.closeCode != null)) { + return Future.error(Exception('WebSocket is not in the OPEN state')); + } + + _logging!(LogLevel.trace, + '(WebSockets transport) sending data. ${getDataDetail(data, _logMessageContent)}.'); + _channel!.sink.add(data); + return Future.value(); + } + + @override + Future stop() { + if (_channel != null) { + _close(null); + } + return Future.value(); + } + + void _close(Exception? error) { + var closeCode = 0; + String? closeReason; + if (_channel != null) { + closeCode = _channel!.closeCode ?? 0; + closeReason = _channel!.closeReason; + _streamSubscription!.cancel(); + _streamSubscription = null; + _channel!.sink.close(); + _channel = null; + } + + _logging!(LogLevel.trace, '(WebSockets transport) socket closed.'); + if (onclose != null) { + if (error != null) { + onclose!(error); + } else { + if (closeCode != 0 && closeCode != 1000) { + onclose!( + Exception( + 'WebSocket closed with status code: $closeCode ($closeReason).'), + ); + } + onclose!(null); + } + } + } +} diff --git a/FinlyticApp/third_party/signalr_core/lib/src/utils.dart b/FinlyticApp/third_party/signalr_core/lib/src/utils.dart new file mode 100644 index 0000000..1afcf97 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/lib/src/utils.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:signalr_core/src/logger.dart'; +import 'package:tuple/tuple.dart'; + +typedef OnReceive = void Function(dynamic data); +typedef OnClose = void Function(Exception? error); +typedef AccessTokenFactory = Future Function(); +typedef Logging = void Function(LogLevel level, String message); + +const String version = '0.0.0-DEV_BUILD'; + +String getDataDetail(dynamic data, bool? includeContent) { + var detail = ''; + + if (data is ByteBuffer) { + detail = 'Binary data of length ${data.lengthInBytes}'; + if (includeContent!) { + detail += '. Content: \'${formatByteBuffer(data)}\''; + } + } else if (data is String) { + detail = 'String data of length \'${data.length}\''; + if (includeContent!) { + detail += '. Content: \'$data\''; + } + } + + return detail; +} + +String formatByteBuffer(ByteBuffer data) { + final view = data.asUint8List(); + + var str = ''; + for (var n in view) { + final pad = n < 16 ? '0' : ''; + str += '0x$pad${n.toStringAsFixed(16)} '; + } + + return str.substring(0, str.length - 1); +} + +Future sendMessage( + Logging? log, + String transportName, + BaseClient? client, + String? url, + AccessTokenFactory? accessTokenFactory, + dynamic content, + bool? logMessageContent, + bool? withCredentials) async { + var headers = {}; + if (accessTokenFactory != null) { + final token = await accessTokenFactory(); + if (token != null) { + headers = { + 'Authorization': 'Bearer $token', + }; + } + } + + final userAgentHeader = getUserAgentHeader(); + headers[userAgentHeader.item1] = userAgentHeader.item2; + + log?.call(LogLevel.trace, + '($transportName transport) sending data. ${getDataDetail(content, logMessageContent)}.'); + + final encoding = (content is ByteBuffer) + ? Encoding.getByName('') + : Encoding.getByName('UTF-8'); + final response = await client?.post(Uri.parse(url ?? ''), + headers: headers, body: content, encoding: encoding); + + log?.call(LogLevel.trace, + '($transportName transport) request complete. Response status: ${response?.statusCode}.'); +} + +Tuple2 getUserAgentHeader() { + var userAgentHeaderName = 'X-SignalR-User-Agent'; + return Tuple2( + userAgentHeaderName, + _constructUserAgent( + version, getOsName(), getRuntime(), getRuntimeVersion())); +} + +String _constructUserAgent( + String version, + String os, + String runtime, + String runtimeVersion, +) { + // Microsoft SignalR/[Version] ([Detailed Version]; [Operating System]; [Runtime]; [Runtime Version]) + var userAgent = 'Microsoft SignalR/'; + + final majorAndMinor = version.split('.'); + userAgent += '${majorAndMinor[0]}.${majorAndMinor[1]}'; + userAgent += ' ($version; '; + + if (os.isNotEmpty) { + userAgent += '$os; '; + } else { + userAgent += 'Unknown OS; '; + } + + userAgent += '$runtime'; + + if (runtimeVersion.isNotEmpty) { + userAgent += '; $runtimeVersion'; + } else { + userAgent += '; Unknown Runtime Version'; + } + + userAgent += ')'; + return userAgent; +} + +String getOsName() { + // TODO: Figure out to determine the platform without using dart:io + return ''; +} + +String getRuntimeVersion() { + return ''; +} + +String getRuntime() { + return ''; +} diff --git a/FinlyticApp/third_party/signalr_core/pubspec.yaml b/FinlyticApp/third_party/signalr_core/pubspec.yaml new file mode 100644 index 0000000..b3e224e --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/pubspec.yaml @@ -0,0 +1,20 @@ +name: signalr_core +description: ASP.NET Core SignalR Client written for Dart that is platform-independent, and can be used on both the command-line and the browser. +version: 1.1.2 +homepage: https://github.com/jamiewest/signalr_core + +environment: + sdk: ^3.2.6 + +dependencies: + http: ^1.2.0 + logging: ^1.2.0 + meta: ^1.9.1 + tuple: ^2.0.2 + web_socket_channel: ^2.4.3 + sse_channel: ^0.1.1 + equatable: ^2.0.5 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.4 diff --git a/FinlyticApp/third_party/signalr_core/test/hub_connection_builder_test.dart b/FinlyticApp/third_party/signalr_core/test/hub_connection_builder_test.dart new file mode 100644 index 0000000..457e937 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/test/hub_connection_builder_test.dart @@ -0,0 +1,80 @@ +import 'package:signalr_core/signalr_core.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'withAutomaticReconnect uses default retryDelays when called with no arguments', + () { + // From DefaultReconnectPolicy.dart + final defaultRetryDelaysInMilliseconds = [0, 2000, 10000, 30000, null]; + final builder = HubConnectionBuilder().withAutomaticReconnect(); + + var retryCount = 0; + for (var delay in defaultRetryDelaysInMilliseconds) { + final retryContext = RetryContext( + previousRetryCount: retryCount++, + elapsedMilliseconds: 0, + retryReason: Exception(), + ); + + expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext), + delay); + } + }); + + test('withAutomaticReconnect uses custom retryDelays when provided', () { + final customRetryDelays = [3, 1, 4, 1, 5, 9]; + final builder = + HubConnectionBuilder().withAutomaticReconnect(customRetryDelays); + + var retryCount = 0; + for (var delay in customRetryDelays) { + final retryContext = RetryContext( + previousRetryCount: retryCount++, + elapsedMilliseconds: 0, + retryReason: Exception(), + ); + + expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext), + delay); + } + + // TODO: This test fails in Dart but looks like works using Typescript's testing framework + // final retryContextFinal = RetryContext( + // previousRetryCount: retryCount++, + // elapsedMilliseconds: 0, + // retryReason: Exception()); + + // expect( + // builder.reconnectPolicy.nextRetryDelayInMilliseconds(retryContextFinal), + // null); + }); + + test('withAutomaticReconnect uses a custom RetryPolicy when provided', () { + final customRetryDelays = [127, 0, 0, 1]; + final builder = HubConnectionBuilder().withAutomaticReconnect( + DefaultReconnectPolicy(retryDelays: customRetryDelays)); + + var retryCount = 0; + for (var delay in customRetryDelays) { + final retryContext = RetryContext( + previousRetryCount: retryCount++, + elapsedMilliseconds: 0, + retryReason: Exception(), + ); + + expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext), + delay); + } + + // TODO: This test fails in Dart but looks like works using Typescript's testing framework + // final retryContextFinal = RetryContext( + // previousRetryCount: retryCount++, + // elapsedMilliseconds: 0, + // retryReason: Exception()); + + // expect( + // builder.reconnectPolicy.nextRetryDelayInMilliseconds(retryContextFinal), + // null); + }); +} diff --git a/FinlyticApp/third_party/signalr_core/test/json_hub_protocol_test.dart b/FinlyticApp/third_party/signalr_core/test/json_hub_protocol_test.dart new file mode 100644 index 0000000..9a5c656 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/test/json_hub_protocol_test.dart @@ -0,0 +1,25 @@ +import 'package:signalr_core/src/hub_protocol.dart'; +import 'package:signalr_core/src/json_hub_protocol.dart'; +import 'package:signalr_core/src/text_message_format.dart'; +import 'package:test/test.dart'; + +void main() { + test('can read ping message', () { + final payload = '{"type":6}${TextMessageFormat.recordSeparator}'; + final messages = JsonHubProtocol() + .parseMessages(payload, (level, message) => print(message)); + expect(messages, equals([PingMessage()])); + }); + + test('can read completion message', () { + final payload = '{"type":3,"invocationId":"0","result":{"data":"123"}}' + '${TextMessageFormat.recordSeparator}'; + final messages = JsonHubProtocol() + .parseMessages(payload, (level, message) => print(message)); + expect( + messages, + equals([ + CompletionMessage(invocationId: '0', result: {'data': '123'}) + ])); + }); +} diff --git a/FinlyticApp/third_party/signalr_core/test/text_message_format_test.dart b/FinlyticApp/third_party/signalr_core/test/text_message_format_test.dart new file mode 100644 index 0000000..e583790 --- /dev/null +++ b/FinlyticApp/third_party/signalr_core/test/text_message_format_test.dart @@ -0,0 +1,35 @@ +import 'package:signalr_core/src/text_message_format.dart'; +import 'package:test/test.dart'; + +const Map> messages = { + '\u001e': [''], + '\u001e\u001e': ['', ''], + 'Hello\u001e': ['Hello'], + 'Hello,\u001eWorld!\u001e': ['Hello,', 'World!'] +}; + +void main() { + group('TextMessageFormat', () { + Map>.from({ + '\u001e': [''], + '\u001e\u001e': ['', ''], + 'Hello\u001e': ['Hello'], + 'Hello,\u001eWorld!\u001e': ['Hello,', 'World!'] + }).forEach((payload, expectedMessages) { + test('should parse \'${Uri.encodeComponent(payload)}\' correctly', () { + final messages = TextMessageFormat.parse(payload); + expect(messages, expectedMessages); + }); + }); + + Map.from({ + '': 'Message is incomplete.', + 'ABC': 'Message is incomplete.', + 'ABC\u001eXYZ': 'Message is incomplete.', + }).forEach((payload, expectedError) { + test('should fail to parse \'${Uri.encodeComponent(payload)}\'', () { + //expect(() => TextMessageFormat.parse(payload), predicate((Exception e) => e is Exception && e.message == 'Message is incomplete.')); + }); + }); + }); +}