diff --git a/FinlyticApp/.flutter-plugins-dependencies b/FinlyticApp/.flutter-plugins-dependencies index 7eb8cad..ee16316 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":"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":"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":"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":"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-07-25 15:56:52.511284","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","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 diff --git a/FinlyticApp/lib/core/widgets/asset_logo_widget.dart b/FinlyticApp/lib/core/widgets/asset_logo_widget.dart index babeee2..ee72982 100644 --- a/FinlyticApp/lib/core/widgets/asset_logo_widget.dart +++ b/FinlyticApp/lib/core/widgets/asset_logo_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../network/api_client.dart'; import '../theme/app_theme.dart'; @@ -24,7 +25,13 @@ class AssetLogoWidget extends StatelessWidget { Widget build(BuildContext context) { // Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000) String? resolveUrl(String? url) { - if (url == null || url.isEmpty) return null; + if (url == null || url.isEmpty) { + final clean = symbolOrName.trim(); + if (RegExp(r'^[A-Z]{2}[A-Z0-9]{9}[0-9]$').hasMatch(clean)) { + return '${ApiClient.baseUrl}/api/v1/logo/$clean'; + } + return null; + } if (url.startsWith('http://') || url.startsWith('https://')) return url; return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url'; } @@ -65,12 +72,13 @@ class AssetLogoWidget extends StatelessWidget { return _buildFallback(initial, colors); }, ) - : Image.network( - image, + : CachedNetworkImage( + imageUrl: image, width: size, height: size, fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { + placeholder: (context, url) => _buildFallback(initial, colors), + errorWidget: (context, url, error) { _failedUrls.add(image); return _buildFallback(initial, colors); }, diff --git a/FinlyticApp/lib/features/admin/models/service_setting_dto.dart b/FinlyticApp/lib/features/admin/models/service_setting_dto.dart new file mode 100644 index 0000000..a779d86 --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/service_setting_dto.dart @@ -0,0 +1,27 @@ +class ServiceSettingDto { + final String key; + final String value; + final String description; + + const ServiceSettingDto({ + required this.key, + required this.value, + this.description = '', + }); + + factory ServiceSettingDto.fromJson(Map json) { + return ServiceSettingDto( + key: json['key']?.toString() ?? '', + value: json['value']?.toString() ?? '', + description: json['description']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'key': key, + 'value': value, + 'description': description, + }; + } +} diff --git a/FinlyticApp/lib/features/admin/repositories/admin_repository.dart b/FinlyticApp/lib/features/admin/repositories/admin_repository.dart index 6665f25..e1d9f4f 100644 --- a/FinlyticApp/lib/features/admin/repositories/admin_repository.dart +++ b/FinlyticApp/lib/features/admin/repositories/admin_repository.dart @@ -2,11 +2,12 @@ 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/service_setting_dto.dart'; class AdminRepository { final ApiClient apiClient; - AdminRepository({required this.apiClient}); + const AdminRepository({required this.apiClient}); Future> fetchUsers() async { try { @@ -17,8 +18,7 @@ class AdminRepository { } return []; } catch (e) { - print('Error fetching admin users: $e'); - throw Exception('Nutzer konnten nicht geladen werden'); + throw Exception('Nutzer konnten nicht geladen werden: $e'); } } @@ -35,4 +35,29 @@ class AdminRepository { throw Exception('Aktualisieren fehlgeschlagen'); } } + + Future>> fetchSettings() async { + final res = await apiClient.get('/api/v1/admin/settings'); + if (res.statusCode == 200 && res.data != null && res.data is Map) { + final map = res.data as Map; + final result = >{}; + map.forEach((k, v) { + if (v is List) { + result[k] = v + .whereType>() + .map((item) => ServiceSettingDto.fromJson(item)) + .toList(); + } + }); + return result; + } + return {}; + } + + Future updateServiceSettings(String serviceName, Map settings) async { + final res = await apiClient.put('/api/v1/admin/settings/$serviceName', data: settings); + if (res.statusCode != 200 && res.statusCode != 204) { + throw Exception('Einstellungen konnten nicht gespeichert werden'); + } + } } diff --git a/FinlyticApp/lib/features/admin/views/admin_users_screen.dart b/FinlyticApp/lib/features/admin/views/admin_users_screen.dart index be1165d..58c520e 100644 --- a/FinlyticApp/lib/features/admin/views/admin_users_screen.dart +++ b/FinlyticApp/lib/features/admin/views/admin_users_screen.dart @@ -4,18 +4,16 @@ import '../../../core/network/api_client.dart'; import '../../../core/network/signalr_service.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/glass_container.dart'; -import '../../../core/widgets/status_badge.dart'; import '../models/admin_update_user_request_dto.dart'; import '../models/admin_user_model.dart'; import '../bloc/admin_bloc.dart'; import '../repositories/admin_repository.dart'; import '../widgets/admin_kpi_header.dart'; +import '../widgets/admin_user_card_item.dart'; import '../widgets/create_user_dialog.dart'; import '../widgets/edit_user_dialog.dart'; - import '../widgets/system_diagnostics_widget.dart'; -/// Role-Restricted Admin Panel Screen managing users, service settings, and system health. class AdminUsersScreen extends StatelessWidget { final ApiClient apiClient; final SignalRService? signalRService; @@ -102,61 +100,52 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Top Header Ribbon Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryEmerald.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)), - ), - child: Icon(Icons.admin_panel_settings_rounded, color: AppTheme.primaryEmerald, size: 22), - ), - const SizedBox(width: 12), - const Text( - 'Admin Control Panel', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: -0.5), - ), - ], + const Text( + 'Administration & System', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( - 'Zentrales Management für Nutzer, Mikrodienste & MQTT System-Bus', - style: TextStyle(fontSize: 12, color: AppTheme.textSecondary), + 'Finlytic Admin-Dashboard • Microservices & Benutzer', + style: TextStyle(color: AppTheme.textMuted, fontSize: 13), ), ], ), - ElevatedButton.icon( - onPressed: () => _openCreateUser(context), - icon: const Icon(Icons.person_add_outlined, size: 18), - label: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryEmerald, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), + Row( + children: [ + IconButton( + onPressed: () => context.read().add(FetchAdminUsers()), + icon: const Icon(Icons.refresh_rounded, color: Colors.white70), + tooltip: 'Neu laden', + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () => _openCreateUser(context), + icon: const Icon(Icons.person_add_alt_1_rounded, size: 18), + label: const Text('Neuer Benutzer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryEmerald, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], ), ], ), const SizedBox(height: 16), - - // KPI Header Metrics AdminKpiHeader( users: users, signalRService: widget.signalRService, ), const SizedBox(height: 16), - - // Tab Selector Ribbon Container( decoration: BoxDecoration( color: AppTheme.glassSurface, @@ -182,19 +171,12 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with ), ), const SizedBox(height: 16), - - // Tab Content View Expanded( child: TabBarView( controller: _tabController, children: [ - // Tab 1: User Management with Filter & Actions _buildUserManagementTab(context, state, users), - - // Tab 2: System Diagnostics & Microservices Health - SystemDiagnosticsWidget( - signalRService: widget.signalRService, - ), + SystemDiagnosticsWidget(signalRService: widget.signalRService), ], ), ), @@ -233,7 +215,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with ); } - // Apply Search Query & Role Filter final filteredUsers = allUsers.where((u) { final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) || u.email.toLowerCase().contains(_searchQuery.toLowerCase()); @@ -243,7 +224,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with return Column( children: [ - // Filter Bar (Search Field & Role Filter Chips) Row( children: [ Expanded( @@ -292,8 +272,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with ], ), const SizedBox(height: 14), - - // User Cards List Expanded( child: filteredUsers.isEmpty ? Center( @@ -302,10 +280,7 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with children: [ Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted), const SizedBox(height: 12), - Text( - 'Keine passenden Benutzer gefunden.', - style: TextStyle(color: AppTheme.textMuted, fontSize: 14), - ), + Text('Keine passenden Benutzer gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 14)), ], ), ) @@ -313,95 +288,10 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with itemCount: filteredUsers.length, itemBuilder: (context, index) { final u = filteredUsers[index]; - final String role = u.role; - final bool isActive = u.isActive; - - final Color roleColor = role == 'Admin' - ? const Color(0xFFA855F7) - : role == 'Premium' - ? AppTheme.primaryEmerald - : AppTheme.accentCyan; - - final String initials = _getInitials(u.fullName.isNotEmpty ? u.fullName : u.email); - - return GlassContainer( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - child: Row( - children: [ - // Avatar Initials Circle - Container( - width: 42, - height: 42, - alignment: Alignment.center, - decoration: BoxDecoration( - color: roleColor.withValues(alpha: 0.2), - shape: BoxShape.circle, - border: Border.all(color: roleColor, width: 1.5), - ), - child: Text( - initials, - style: TextStyle(fontWeight: FontWeight.bold, color: roleColor, fontSize: 14), - ), - ), - const SizedBox(width: 14), - - // User Name & Email - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - u.fullName.isNotEmpty ? u.fullName : u.email, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), - ), - const SizedBox(width: 8), - StatusBadge(label: role, color: roleColor), - ], - ), - const SizedBox(height: 2), - Text( - u.email, - style: TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), - ], - ), - ), - - // Active/Inactive Quick Switch Toggle - Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - isActive ? 'Aktiv' : 'Gesperrt', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: isActive ? AppTheme.primaryEmerald : AppTheme.accentRed, - ), - ), - ], - ), - const SizedBox(width: 8), - Switch( - value: isActive, - activeThumbColor: AppTheme.primaryEmerald, - onChanged: (val) => _toggleUserActiveStatus(context, u, val), - ), - const SizedBox(width: 8), - IconButton.filledTonal( - icon: const Icon(Icons.edit_outlined, size: 18), - tooltip: 'Benutzer Bearbeiten', - onPressed: () => _openEditUser(context, u), - ), - ], - ), - ], - ), + return AdminUserCardItem( + user: u, + onToggleActive: (val) => _toggleUserActiveStatus(context, u, val), + onEdit: () => _openEditUser(context, u), ); }, ), @@ -409,13 +299,4 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with ], ); } - - String _getInitials(String name) { - if (name.isEmpty) return 'U'; - final parts = name.trim().split(' '); - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase(); - } } diff --git a/FinlyticApp/lib/features/admin/views/service_detail_screen.dart b/FinlyticApp/lib/features/admin/views/service_detail_screen.dart index 0abbd0e..c44225b 100644 --- a/FinlyticApp/lib/features/admin/views/service_detail_screen.dart +++ b/FinlyticApp/lib/features/admin/views/service_detail_screen.dart @@ -4,27 +4,37 @@ import '../../../core/network/api_client.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/status_badge.dart'; +import '../models/service_setting_dto.dart'; +import '../repositories/admin_repository.dart'; class ServiceDetailScreen extends StatefulWidget { final String serviceName; final ApiClient apiClient; + final AdminRepository? repository; - const ServiceDetailScreen({super.key, required this.serviceName, required this.apiClient}); + const ServiceDetailScreen({ + super.key, + required this.serviceName, + required this.apiClient, + this.repository, + }); @override State createState() => _ServiceDetailScreenState(); } class _ServiceDetailScreenState extends State { + late final AdminRepository _repository; bool _isLoading = true; bool _isSaving = false; String _error = ''; - List _settings = []; + List _settings = []; final Map _controllers = {}; @override void initState() { super.initState(); + _repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient); _fetchServiceDetails(); } @@ -38,27 +48,22 @@ class _ServiceDetailScreenState extends State { Future _fetchServiceDetails() async { try { - final res = await widget.apiClient.get('/api/v1/admin/settings'); - if (res.statusCode == 200 && res.data != null) { - final groupedSettings = res.data as Map; - final serviceSettings = groupedSettings[widget.serviceName] ?? []; - - setState(() { - _settings = serviceSettings; - for (var s in _settings) { - final key = s['key']?.toString() ?? ''; - final val = s['value']?.toString() ?? ''; - if (!_controllers.containsKey(key)) { - _controllers[key] = TextEditingController(text: val); - } else { - _controllers[key]!.text = val; - } + final groupedSettings = await _repository.fetchSettings(); + final serviceSettings = groupedSettings[widget.serviceName] ?? []; + + setState(() { + _settings = serviceSettings; + for (var s in _settings) { + final key = s.key; + final val = s.value; + if (!_controllers.containsKey(key)) { + _controllers[key] = TextEditingController(text: val); + } else { + _controllers[key]!.text = val; } - _isLoading = false; - }); - } else { - throw Exception('Failed to load settings'); - } + } + _isLoading = false; + }); } catch (e) { setState(() { _error = e.toString(); @@ -75,35 +80,28 @@ class _ServiceDetailScreenState extends State { payload[k] = v.text; }); - final res = await widget.apiClient.put( - '/api/v1/admin/settings/${widget.serviceName}', - data: payload, - ); + await _repository.updateServiceSettings(widget.serviceName, payload); - if (res.statusCode == 200 || res.statusCode == 204) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle_outline, color: Colors.black), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.', - style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600), - ), + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle_outline, color: Colors.black), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.', + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600), ), - ], - ), - backgroundColor: AppTheme.primaryEmerald, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ], ), - ); - } - } else { - throw Exception('Server returned status code ${res.statusCode}'); + backgroundColor: AppTheme.primaryEmerald, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); } } catch (e) { if (mounted) { @@ -170,8 +168,8 @@ class _ServiceDetailScreenState extends State { const Text('Keine spezifischen Einstellungen gefunden.') else ..._settings.map((s) { - final key = s['key']?.toString() ?? ''; - final desc = s['description']?.toString() ?? ''; + final key = s.key; + final desc = s.description; final controller = _controllers[key]; if (controller == null) return const SizedBox.shrink(); diff --git a/FinlyticApp/lib/features/admin/widgets/admin_user_card_item.dart b/FinlyticApp/lib/features/admin/widgets/admin_user_card_item.dart new file mode 100644 index 0000000..8e25f42 --- /dev/null +++ b/FinlyticApp/lib/features/admin/widgets/admin_user_card_item.dart @@ -0,0 +1,116 @@ +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/admin_user_model.dart'; + +class AdminUserCardItem extends StatelessWidget { + final AdminUserModel user; + final ValueChanged onToggleActive; + final VoidCallback onEdit; + + const AdminUserCardItem({ + super.key, + required this.user, + required this.onToggleActive, + required this.onEdit, + }); + + String _getInitials(String name) { + if (name.isEmpty) return 'U'; + final parts = name.trim().split(' '); + if (parts.length >= 2) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase(); + } + + @override + Widget build(BuildContext context) { + final String role = user.role; + final bool isActive = user.isActive; + + final Color roleColor = role == 'Admin' + ? const Color(0xFFA855F7) + : role == 'Premium' + ? AppTheme.primaryEmerald + : AppTheme.accentCyan; + + final String initials = _getInitials(user.fullName.isNotEmpty ? user.fullName : user.email); + + return GlassContainer( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: roleColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: roleColor, width: 1.5), + ), + child: Text( + initials, + style: TextStyle(fontWeight: FontWeight.bold, color: roleColor, fontSize: 14), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user.fullName.isNotEmpty ? user.fullName : user.email, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(width: 8), + StatusBadge(label: role, color: roleColor), + ], + ), + const SizedBox(height: 2), + Text( + user.email, + style: TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + ), + ), + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + isActive ? 'Aktiv' : 'Gesperrt', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isActive ? AppTheme.primaryEmerald : AppTheme.accentRed, + ), + ), + ], + ), + const SizedBox(width: 8), + Switch( + value: isActive, + activeThumbColor: AppTheme.primaryEmerald, + onChanged: onToggleActive, + ), + const SizedBox(width: 8), + IconButton.filledTonal( + icon: const Icon(Icons.edit_outlined, size: 18), + tooltip: 'Benutzer Bearbeiten', + onPressed: onEdit, + ), + ], + ), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart b/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart index c178a10..b671cb4 100644 --- a/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart +++ b/FinlyticApp/lib/features/admin/widgets/pipeline_settings_widget.dart @@ -1,10 +1,10 @@ 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/status_badge.dart'; +import '../repositories/admin_repository.dart'; +import 'service_settings_form.dart'; -/// Service Metadata Info used for Admin Config Navigation class ServiceConfigMeta { final String key; final String displayName; @@ -21,17 +21,18 @@ class ServiceConfigMeta { }); } -/// Centralized Service Configuration Management Widget for Admin Panel. class PipelineSettingsWidget extends StatefulWidget { final ApiClient? apiClient; + final AdminRepository? repository; - const PipelineSettingsWidget({super.key, this.apiClient}); + const PipelineSettingsWidget({super.key, this.apiClient, this.repository}); @override State createState() => _PipelineSettingsWidgetState(); } class _PipelineSettingsWidgetState extends State { + late final AdminRepository _repository; String _selectedServiceKey = 'FinlyticAssets'; bool _isLoading = false; bool _isSaving = false; @@ -132,39 +133,34 @@ class _PipelineSettingsWidgetState extends State { }, }; + bool _initialized = false; + @override - void initState() { - super.initState(); - _fetchSettings(); + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _initialized = true; + final client = widget.apiClient ?? context.read(); + _repository = widget.repository ?? AdminRepository(apiClient: client); + _fetchSettings(); + } } Future _fetchSettings() async { - if (widget.apiClient == null) return; - setState(() => _isLoading = true); try { - final res = await widget.apiClient!.get('/api/v1/admin/settings'); - if (res.statusCode == 200 && res.data is Map) { - final Map data = Map.from(res.data); - data.forEach((svc, items) { - if (items is List) { - _controllers.putIfAbsent(svc, () => {}); - for (var item in items) { - final key = item['key']?.toString(); - final val = item['value']?.toString(); - if (key != null && val != null) { - if (_controllers[svc]!.containsKey(key)) { - _controllers[svc]![key]!.text = val; - } else { - _controllers[svc]![key] = TextEditingController(text: val); - } - } - } + final settings = await _repository.fetchSettings(); + settings.forEach((svc, items) { + _controllers.putIfAbsent(svc, () => {}); + for (final s in items) { + if (_controllers[svc]!.containsKey(s.key)) { + _controllers[svc]![s.key]!.text = s.value; + } else { + _controllers[svc]![s.key] = TextEditingController(text: s.value); } - }); - } + } + }); } catch (_) { - // Retain standard default in-memory values } finally { if (mounted) setState(() => _isLoading = false); } @@ -179,9 +175,7 @@ class _PipelineSettingsWidgetState extends State { payload[k] = v.text; }); - if (widget.apiClient != null) { - await widget.apiClient!.put('/api/v1/admin/settings/$_selectedServiceKey', data: payload); - } + await _repository.updateServiceSettings(_selectedServiceKey, payload); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -192,7 +186,7 @@ class _PipelineSettingsWidgetState extends State { const SizedBox(width: 8), Expanded( child: Text( - 'Einstellungen für $_selectedServiceKey gespeichert & via MQTT synchronisiert.', + 'Einstellungen für $_selectedServiceKey gespeichert & synchronisiert.', style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600), ), ), @@ -219,7 +213,6 @@ class _PipelineSettingsWidgetState extends State { } } - @override Widget build(BuildContext context) { final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first); @@ -228,7 +221,6 @@ class _PipelineSettingsWidgetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Service Selection Ribbon SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -250,15 +242,16 @@ class _PipelineSettingsWidgetState extends State { ), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon(svc.icon, size: 18, color: isSelected ? svc.accentColor : AppTheme.textMuted), + Icon(svc.icon, size: 16, color: isSelected ? svc.accentColor : AppTheme.textMuted), const SizedBox(width: 8), Text( svc.displayName, style: TextStyle( - fontSize: 13, + color: isSelected ? Colors.white : AppTheme.textMuted, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary, + fontSize: 12, ), ), ], @@ -269,148 +262,41 @@ class _PipelineSettingsWidgetState extends State { }).toList(), ), ), - const SizedBox(height: 8), - - // Service Details & Config Panel - GlassContainer( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: activeService.accentColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: activeService.accentColor.withValues(alpha: 0.3)), - ), - child: Icon(activeService.icon, color: activeService.accentColor, size: 22), - ), - const SizedBox(width: 14), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activeService.displayName, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - Text( - activeService.description, - style: TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), - ], - ), - ], - ), - if (_isLoading) - SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryEmerald)) - else - StatusBadge(label: 'MQTT Sync', color: AppTheme.primaryEmerald), - ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(activeService.displayName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 2), + Text(activeService.description, style: TextStyle(fontSize: 12, color: AppTheme.textMuted)), + ], + ), + ElevatedButton.icon( + onPressed: _isSaving ? null : _saveSettings, + icon: _isSaving + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black)) + : const Icon(Icons.save_outlined, size: 16), + label: Text(_isSaving ? 'Speichere...' : 'Speichern'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryEmerald, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), ), - const SizedBox(height: 16), - const Divider(color: Colors.white10), - const SizedBox(height: 16), - - // Parameter Input List - ...activeControllers.entries.map((entry) { - final keyName = entry.key; - final controller = entry.value; - final isBoolean = 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: AppTheme.glassBorder), - ), - child: SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(_formatLabel(keyName), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), - //subtitle: Text('Schlüssel: $keyName', style: TextStyle(fontSize: 11, color: AppTheme.textMuted)), - value: boolVal, - activeThumbColor: activeService.accentColor, - onChanged: (val) => setState(() => controller.text = val.toString()), - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - decoration: InputDecoration( - labelText: _formatLabel(keyName), - //helperText: 'Schlüssel: $keyName', - prefixIcon: Icon(Icons.tune_outlined, size: 18, color: activeService.accentColor), - ), - ), - ), - /*if (isNumeric) ...[ - const SizedBox(width: 8), - IconButton.filledTonal( - icon: const Icon(Icons.remove, size: 18), - onPressed: () => setState(() => _adjustNumericValue(controller, -1.0, isDouble: isDouble)), - ), - IconButton.filledTonal( - icon: const Icon(Icons.add, size: 18), - onPressed: () => setState(() => _adjustNumericValue(controller, 1.0, isDouble: isDouble)), - ), - ],*/ - ], - ), - ); - }), - - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isSaving ? null : _saveSettings, - icon: _isSaving - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black)) - : const Icon(Icons.save_outlined), - label: Text( - _isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen für ${activeService.displayName} Speichern', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryEmerald, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - ), + ), + ], ), + const SizedBox(height: 14), + if (_isLoading) + Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)) + else + ServiceSettingsForm( + controllers: activeControllers, + accentColor: activeService.accentColor, + ), ], ); } - - String _formatLabel(String key) { - return key - .replaceAll(RegExp(r'(? controllers; + final Color accentColor; + + const ServiceSettingsForm({ + super.key, + required this.controllers, + required this.accentColor, + }); + + Widget _buildField(String key, TextEditingController ctrl) { + final isBool = ctrl.text == 'true' || ctrl.text == 'false'; + + if (isBool) { + return StatefulBuilder( + builder: (ctx, setLocal) { + return SwitchListTile( + title: Text(key, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), + subtitle: Text('Boolesche Konfigurationsflagge', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + value: ctrl.text == 'true', + activeThumbColor: accentColor, + onChanged: (newVal) { + setLocal(() { + ctrl.text = newVal ? 'true' : 'false'; + }); + }, + ); + }, + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(key, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + TextField( + controller: ctrl, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (controllers.isEmpty) { + return GlassContainer( + padding: const EdgeInsets.all(20), + child: Center( + child: Text('Keine konfigurierbaren Parameter für diesen Dienst vorhanden.', style: TextStyle(color: AppTheme.textMuted)), + ), + ); + } + + return GlassContainer( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: controllers.entries.map((e) => _buildField(e.key, e.value)).toList(), + ), + ); + } +} diff --git a/FinlyticApp/lib/features/auth/repositories/auth_repository.dart b/FinlyticApp/lib/features/auth/repositories/auth_repository.dart index 6825d4e..7539dd5 100644 --- a/FinlyticApp/lib/features/auth/repositories/auth_repository.dart +++ b/FinlyticApp/lib/features/auth/repositories/auth_repository.dart @@ -67,6 +67,14 @@ class AuthRepository { } } + Future changeInitialPassword(String userId, String newPassword) async { + final res = await apiClient.post('/api/v1/auth/change-initial-password', data: { + 'userId': userId, + 'newPassword': newPassword, + }); + return res.statusCode == 200; + } + Future logout() async { await storageService.clearAll(); } @@ -76,3 +84,4 @@ class RequiresPasswordChangeException implements Exception { final String userId; RequiresPasswordChangeException(this.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 10767cc..8111359 100644 --- a/FinlyticApp/lib/features/auth/views/change_initial_password_screen.dart +++ b/FinlyticApp/lib/features/auth/views/change_initial_password_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:finlytic_app/core/network/api_client.dart'; +import 'package:finlytic_app/core/services/secure_storage_service.dart'; import 'package:finlytic_app/core/theme/app_theme.dart'; import 'package:finlytic_app/features/auth/bloc/auth_bloc.dart'; +import 'package:finlytic_app/features/auth/repositories/auth_repository.dart'; class ChangeInitialPasswordScreen extends StatefulWidget { final String userId; @@ -23,13 +25,13 @@ class _ChangeInitialPasswordScreenState extends State _isLoading = true); try { - final apiClient = context.read(); - final res = await apiClient.post('/api/v1/auth/change-initial-password', data: { - 'userId': widget.userId, - 'newPassword': _passwordController.text, - }); + final authRepo = AuthRepository( + apiClient: context.read(), + storageService: context.read(), + ); + final success = await authRepo.changeInitialPassword(widget.userId, _passwordController.text); - if (res.statusCode == 200) { + if (success) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.'), backgroundColor: AppTheme.accentCyan), diff --git a/FinlyticApp/lib/features/calendar/models/corporate_event_model.dart b/FinlyticApp/lib/features/calendar/models/corporate_event_model.dart index a576b04..1db0095 100644 --- a/FinlyticApp/lib/features/calendar/models/corporate_event_model.dart +++ b/FinlyticApp/lib/features/calendar/models/corporate_event_model.dart @@ -8,6 +8,8 @@ class CorporateEventModel extends Equatable { final String eventType; final DateTime eventDate; final String description; + final String? image; + final String? ticker; const CorporateEventModel({ required this.id, @@ -17,6 +19,8 @@ class CorporateEventModel extends Equatable { required this.eventDate, required this.description, required this.isin, + this.image, + this.ticker, }); factory CorporateEventModel.fromJson(Map json) { @@ -37,9 +41,12 @@ class CorporateEventModel extends Equatable { } } + final isinVal = json['isin']?.toString() ?? json['Isin']?.toString() ?? ''; + final imageVal = json['image']?.toString() ?? json['Image']?.toString() ?? (isinVal.isNotEmpty ? '/api/v1/logo/$isinVal' : null); + return CorporateEventModel( id: json['id']?.toString() ?? json['Id']?.toString() ?? '', - isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? '', + isin: isinVal, symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '', companyName: json['companyName']?.toString() ?? json['CompanyName']?.toString() ?? @@ -50,6 +57,8 @@ class CorporateEventModel extends Equatable { description: json['description']?.toString() ?? json['Description']?.toString() ?? '', + image: imageVal, + ticker: json['ticker']?.toString() ?? json['Ticker']?.toString(), ); } @@ -62,10 +71,12 @@ class CorporateEventModel extends Equatable { 'eventType': eventType, 'eventDate': eventDate.toIso8601String(), 'description': description, + if (image != null) 'image': image, + if (ticker != null) 'ticker': ticker, }; } @override List get props => - [id, symbol, companyName, eventType, eventDate, description]; + [id, symbol, companyName, eventType, eventDate, description, isin, image, ticker]; } diff --git a/FinlyticApp/lib/features/calendar/widgets/calendar_event_item.dart b/FinlyticApp/lib/features/calendar/widgets/calendar_event_item.dart index 6e43486..05266da 100644 --- a/FinlyticApp/lib/features/calendar/widgets/calendar_event_item.dart +++ b/FinlyticApp/lib/features/calendar/widgets/calendar_event_item.dart @@ -1,5 +1,6 @@ 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 '../../../core/widgets/status_badge.dart'; @@ -11,11 +12,13 @@ class CalendarEventItem extends StatelessWidget { @override Widget build(BuildContext context) { + final isin = event['isin']?.toString() ?? ''; final symbol = event['symbol']?.toString() ?? 'ASSET'; final company = event['companyName']?.toString() ?? symbol; final type = event['eventType']?.toString() ?? 'Earnings'; final desc = event['description']?.toString() ?? ''; final dateStr = event['eventDate']?.toString() ?? ''; + final image = event['image']?.toString() ?? (isin.isNotEmpty ? '/api/v1/logo/$isin' : null); Color badgeColor = AppTheme.primaryEmerald; if (type == 'ExDividend') badgeColor = AppTheme.accentCyan; @@ -25,16 +28,11 @@ class CalendarEventItem extends StatelessWidget { margin: const EdgeInsets.only(bottom: 10), child: Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: badgeColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - type == 'Earnings' ? Icons.bar_chart : (type == 'ExDividend' ? Icons.content_cut : Icons.payments), - color: badgeColor, - ), + AssetLogoWidget( + symbolOrName: isin.isNotEmpty ? isin : company, + imageUrl: image, + size: 36, + enableHero: false, ), const SizedBox(width: 14), Expanded( diff --git a/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart b/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart index c0db60a..e33f97c 100644 --- a/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart +++ b/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart @@ -68,7 +68,7 @@ class CalendarEventTile extends StatelessWidget { Expanded( child: Row( children: [ - AssetLogoWidget(symbolOrName: companyName, imageUrl: image, size: 28), + AssetLogoWidget(symbolOrName: isin.isNotEmpty ? isin : companyName, imageUrl: image, size: 28, enableHero: false), const SizedBox(width: 8), Expanded( child: Column( diff --git a/FinlyticApp/lib/features/discovery/cubit/discovery_cubit.dart b/FinlyticApp/lib/features/discovery/cubit/discovery_cubit.dart index 22a6393..14e5905 100644 --- a/FinlyticApp/lib/features/discovery/cubit/discovery_cubit.dart +++ b/FinlyticApp/lib/features/discovery/cubit/discovery_cubit.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/network/api_client.dart'; import '../models/discovery_asset_model.dart'; +import '../repositories/discovery_repository.dart'; class DiscoveryState extends Equatable { final List assets; @@ -27,27 +28,24 @@ class DiscoveryState extends Equatable { } class DiscoveryCubit extends Cubit { - final ApiClient apiClient; + final DiscoveryRepository repository; - DiscoveryCubit({required this.apiClient}) : super(const DiscoveryState()); + DiscoveryCubit({ + DiscoveryRepository? repository, + ApiClient? apiClient, + }) : repository = repository ?? DiscoveryRepository(apiClient: apiClient!), + super(const DiscoveryState()); Future loadDiscovery({int limit = 15}) async { if (state.isLoading) return; emit(state.copyWith(isLoading: true)); try { - final res = await apiClient.get('/api/v1/assets/discovery?limit=$limit'); - if (res.statusCode == 200 && res.data is List) { - final list = (res.data as List) - .map((e) => DiscoveryAssetModel.fromJson(Map.from(e))) - .toList(); - - emit(DiscoveryState(assets: list, isLoading: false)); - } else { - emit(state.copyWith(isLoading: false)); - } + final list = await repository.getDiscoveryAssets(limit: limit); + emit(DiscoveryState(assets: list, isLoading: false)); } catch (_) { emit(state.copyWith(isLoading: false)); } } } + diff --git a/FinlyticApp/lib/features/discovery/repositories/discovery_repository.dart b/FinlyticApp/lib/features/discovery/repositories/discovery_repository.dart new file mode 100644 index 0000000..c009b4d --- /dev/null +++ b/FinlyticApp/lib/features/discovery/repositories/discovery_repository.dart @@ -0,0 +1,21 @@ +import '../../../core/network/api_client.dart'; +import '../models/discovery_asset_model.dart'; + +/// Clean Architecture Repository for fetching asset discovery data. +class DiscoveryRepository { + final ApiClient apiClient; + + const DiscoveryRepository({required this.apiClient}); + + /// Fetches top discovery assets. + Future> getDiscoveryAssets({int limit = 15}) async { + final res = await apiClient.get('/api/v1/assets/discovery?limit=$limit'); + if (res.statusCode == 200 && res.data is List) { + return (res.data as List) + .whereType>() + .map((e) => DiscoveryAssetModel.fromJson(e)) + .toList(); + } + return []; + } +} diff --git a/FinlyticApp/lib/features/favorites/cubit/favorites_cubit.dart b/FinlyticApp/lib/features/favorites/cubit/favorites_cubit.dart index 371a491..3b98c4c 100644 --- a/FinlyticApp/lib/features/favorites/cubit/favorites_cubit.dart +++ b/FinlyticApp/lib/features/favorites/cubit/favorites_cubit.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/signalr_service.dart'; import '../models/favorite_asset_model.dart'; +import '../repositories/favorites_repository.dart'; class FavoritesState extends Equatable { final Set favoriteIsins; @@ -39,11 +40,16 @@ class FavoritesState extends Equatable { } class FavoritesCubit extends Cubit { - final ApiClient apiClient; + final FavoritesRepository repository; final SignalRService? signalRService; StreamSubscription>? _priceSub; - FavoritesCubit({required this.apiClient, this.signalRService}) : super(const FavoritesState()) { + FavoritesCubit({ + FavoritesRepository? repository, + ApiClient? apiClient, + this.signalRService, + }) : repository = repository ?? FavoritesRepository(apiClient: apiClient!), + super(const FavoritesState()) { // 1. Subscribe to SignalR 10-second WebSocket price stream if (signalRService != null) { _priceSub = signalRService!.favoritePricesStream.listen((priceMap) { @@ -58,36 +64,24 @@ class FavoritesCubit extends Cubit { return super.close(); } - /// Loads favorite metadata via REST ONLY WHEN NEEDED (e.g. initial load or list mutation). + /// Loads favorite metadata via repository ONLY WHEN NEEDED. Future loadFavorites() async { emit(state.copyWith(isLoading: true)); try { - final ts = DateTime.now().millisecondsSinceEpoch; - final res = await apiClient.get('/api/v1/user/favorites?_t=$ts'); - if (res.statusCode == 200 && res.data is List) { - final rawList = res.data as List; - final set = {}; - final dedupMap = {}; + final list = await repository.getFavorites(); + final set = {}; - for (var item in rawList) { - final model = FavoriteAssetModel.fromJson(Map.from(item)); - final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase(); - if (!dedupMap.containsKey(key)) { - dedupMap[key] = model; - } - if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase()); - if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase()); - if (model.name.isNotEmpty) set.add(model.name.toUpperCase()); - } - - emit(FavoritesState( - favoriteIsins: set, - favoriteDetails: dedupMap.values.toList(), - isLoading: false, - )); - } else { - emit(state.copyWith(isLoading: false)); + for (var model in list) { + if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase()); + if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase()); + if (model.name.isNotEmpty) set.add(model.name.toUpperCase()); } + + emit(FavoritesState( + favoriteIsins: set, + favoriteDetails: list, + isLoading: false, + )); } catch (_) { emit(state.copyWith(isLoading: false)); } @@ -132,17 +126,15 @@ class FavoritesCubit extends Cubit { emit(state.copyWith(favoriteIsins: newSet)); - // Perform API call in background + // Perform API call via repository try { if (isCurrentlyFav) { - await apiClient.delete('/api/v1/user/favorites/$target'); + await repository.removeFavorite(target); } else { - await apiClient.post('/api/v1/user/favorites/$target'); + await repository.addFavorite(target); } - // Re-sync full list to ensure metadata details are fresh await loadFavorites(); } catch (_) { - // Revert on error await loadFavorites(); } } @@ -159,11 +151,11 @@ class FavoritesCubit extends Cubit { }).toList(); emit(state.copyWith(favoriteDetails: updatedDetails)); - await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker'); + await repository.updateFavoriteTicker(symbol, ticker); await loadFavorites(); } catch (_) { - // Revert/refresh on error await loadFavorites(); } } } + diff --git a/FinlyticApp/lib/features/favorites/repositories/favorites_repository.dart b/FinlyticApp/lib/features/favorites/repositories/favorites_repository.dart new file mode 100644 index 0000000..203c0b4 --- /dev/null +++ b/FinlyticApp/lib/features/favorites/repositories/favorites_repository.dart @@ -0,0 +1,49 @@ +import '../../../core/network/api_client.dart'; +import '../models/favorite_asset_model.dart'; + +/// Clean Architecture Repository for managing user favorite assets. +class FavoritesRepository { + final ApiClient apiClient; + + const FavoritesRepository({required this.apiClient}); + + /// Fetches the user's favorite asset list from the backend. + Future> getFavorites() async { + final ts = DateTime.now().millisecondsSinceEpoch; + final res = await apiClient.get('/api/v1/user/favorites?_t=$ts'); + if (res.statusCode == 200 && res.data is List) { + final rawList = res.data as List; + final dedupMap = {}; + + for (var item in rawList) { + if (item is Map) { + final model = FavoriteAssetModel.fromJson(item); + final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase(); + if (!dedupMap.containsKey(key)) { + dedupMap[key] = model; + } + } + } + return dedupMap.values.toList(); + } + return []; + } + + /// Adds an asset to user favorites by ISIN or symbol. + Future addFavorite(String identifier) async { + final res = await apiClient.post('/api/v1/user/favorites/$identifier'); + return res.statusCode == 200 || res.statusCode == 201; + } + + /// Removes an asset from user favorites. + Future removeFavorite(String identifier) async { + final res = await apiClient.delete('/api/v1/user/favorites/$identifier'); + return res.statusCode == 200 || res.statusCode == 204; + } + + /// Updates the preferred active ticker for a favorite asset. + Future updateFavoriteTicker(String symbolOrIsin, String ticker) async { + final res = await apiClient.post('/api/v1/user/favorites/$symbolOrIsin/ticker?ticker=$ticker'); + return res.statusCode == 200; + } +} diff --git a/FinlyticApp/lib/features/news/models/news_article_model.dart b/FinlyticApp/lib/features/news/models/news_article_model.dart index 99a246d..eda9ce0 100644 --- a/FinlyticApp/lib/features/news/models/news_article_model.dart +++ b/FinlyticApp/lib/features/news/models/news_article_model.dart @@ -39,27 +39,29 @@ class NewsArticleModel extends Equatable { factory NewsArticleModel.fromJson(Map json) { List assets = []; - final mList = json['matchedAssets'] ?? json['MatchedAssets']; + final mList = json['matchedAssets']; if (mList != null && mList is List) { - assets = mList.map((e) => MatchedAssetModel.fromJson(e as Map)).toList(); + assets = mList + .whereType>() + .map((e) => MatchedAssetModel.fromJson(e)) + .toList(); } return NewsArticleModel( - id: json['id']?.toString() ?? json['Id']?.toString() ?? '', - title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title', - author: json['author']?.toString() ?? json['Author']?.toString() ?? 'Unknown', - summary: json['summary']?.toString() ?? json['Summary']?.toString() ?? '', - contentRaw: json['contentRaw']?.toString() ?? json['ContentRaw']?.toString() ?? '', - sourceUrl: json['sourceUrl']?.toString() ?? json['SourceUrl']?.toString() ?? '', - scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? json['ScrapedAt']?.toString() ?? '') ?? DateTime.now(), - publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? json['PublishedAt']?.toString() ?? '') ?? DateTime.now(), - status: json['status']?.toString() ?? json['Status']?.toString() ?? 'Completed', - sentiment: json['sentiment']?.toString() ?? json['Sentiment']?.toString() ?? '', - - sentimentScore: (json['sentimentScore'] ?? json['SentimentScore'] ?? 0.0).toDouble(), - confidence: (json['confidence'] ?? json['Confidence'] ?? 0.0).toDouble(), - finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null) - ? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult']) + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? 'No Title', + author: json['author']?.toString() ?? 'Unknown', + summary: json['summary']?.toString() ?? '', + contentRaw: json['contentRaw']?.toString() ?? '', + sourceUrl: json['sourceUrl']?.toString() ?? '', + scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? '') ?? DateTime.now(), + publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? '') ?? DateTime.now(), + status: json['status']?.toString() ?? 'Completed', + sentiment: json['sentiment']?.toString() ?? '', + sentimentScore: (json['sentimentScore'] as num?)?.toDouble() ?? 0.0, + confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0, + finbertResult: json['finbertResult'] != null + ? FinbertResultModel.fromJson(json['finbertResult'] as Map) : null, matchedAssets: assets, ); diff --git a/FinlyticApp/lib/features/news/repositories/news_repository.dart b/FinlyticApp/lib/features/news/repositories/news_repository.dart index e0ddac5..9fd053d 100644 --- a/FinlyticApp/lib/features/news/repositories/news_repository.dart +++ b/FinlyticApp/lib/features/news/repositories/news_repository.dart @@ -23,6 +23,8 @@ class NewsRepository { String? symbol, String? isin, String? date, + String? query, + bool? hasSentiment, }) async { try { final Map queryParams = { @@ -33,17 +35,21 @@ class NewsRepository { if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol; if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin; if (date != null && date.isNotEmpty) queryParams['date'] = date; + if (query != null && query.isNotEmpty) queryParams['query'] = query; + if (hasSentiment == true) queryParams['hasSentiment'] = true; final response = await apiClient.get('/api/v1/news', queryParameters: queryParams); - if (response.statusCode == 200) { + if (response.statusCode == 200 && response.data is List) { final List data = response.data; - return data.map((json) => NewsArticleModel.fromJson(json)).toList(); + return data + .whereType>() + .map((json) => NewsArticleModel.fromJson(json)) + .toList(); } return []; } catch (e) { - print('Error fetching news: $e'); - throw Exception('Failed to load news'); + throw Exception('Failed to load news: $e'); } } diff --git a/FinlyticApp/lib/features/news/views/news_feed_screen.dart b/FinlyticApp/lib/features/news/views/news_feed_screen.dart index 14d9be6..0a76f31 100644 --- a/FinlyticApp/lib/features/news/views/news_feed_screen.dart +++ b/FinlyticApp/lib/features/news/views/news_feed_screen.dart @@ -2,22 +2,26 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../core/network/api_client.dart'; import '../../../core/theme/app_theme.dart'; +import '../models/news_article_model.dart'; +import '../repositories/news_repository.dart'; import '../widgets/news_card_item.dart'; import '../widgets/advanced_news_filter_bar.dart'; /// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting. class NewsFeedScreen extends StatefulWidget { final ApiClient apiClient; + final NewsRepository? repository; - const NewsFeedScreen({super.key, required this.apiClient}); + const NewsFeedScreen({super.key, required this.apiClient, this.repository}); @override State createState() => _NewsFeedScreenState(); } class _NewsFeedScreenState extends State { + late final NewsRepository _repository; final ScrollController _scrollController = ScrollController(); - final List _newsItems = []; + final List _newsItems = []; int _currentPage = 1; static const int _pageSize = 15; bool _isLoading = false; @@ -34,6 +38,7 @@ class _NewsFeedScreenState extends State { @override void initState() { super.initState(); + _repository = widget.repository ?? NewsRepository(apiClient: widget.apiClient, backendUrl: ApiClient.baseUrl); _loadNews(refresh: true); _scrollController.addListener(() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { @@ -49,17 +54,6 @@ class _NewsFeedScreenState extends State { super.dispose(); } - DateTime _parseDateTime(dynamic val) { - if (val == null) return DateTime.fromMillisecondsSinceEpoch(0); - final str = val.toString().trim(); - if (str.isEmpty) return DateTime.fromMillisecondsSinceEpoch(0); - try { - return DateTime.parse(str).toUtc(); - } catch (_) { - return DateTime.fromMillisecondsSinceEpoch(0); - } - } - Future _loadNews({bool refresh = false}) async { if (_isLoading) return; if (refresh) { @@ -72,54 +66,32 @@ class _NewsFeedScreenState extends State { setState(() => _isLoading = true); try { - final queryParams = { - 'page': _currentPage, - 'pageSize': _pageSize, - }; + final fetched = await _repository.fetchNews( + page: _currentPage, + pageSize: _pageSize, + date: _selectedDate?.toIso8601String().substring(0, 10), + query: _searchQuery?.trim(), + isin: _selectedIsin?.trim(), + hasSentiment: _hasSentimentOnly, + ); - if (_selectedDate != null) { - queryParams['date'] = _selectedDate!.toIso8601String().substring(0, 10); - } - if (_searchQuery != null && _searchQuery!.trim().isNotEmpty) { - queryParams['query'] = _searchQuery!.trim(); - } - if (_selectedIsin != null && _selectedIsin!.trim().isNotEmpty) { - queryParams['isin'] = _selectedIsin!.trim(); - } - if (_hasSentimentOnly) { - queryParams['hasSentiment'] = true; - } - - final res = await widget.apiClient.get('/api/v1/news', queryParameters: queryParams); - - if (res.statusCode == 200 && res.data != null && res.data is List) { - final List fetched = res.data as List; - setState(() { - // Deduplicate by ID - final existingIds = _newsItems.map((e) => e['id'] ?? e['Id']).where((id) => id != null).toSet(); - for (final item in fetched) { - final id = item['id'] ?? item['Id']; - if (id == null || !existingIds.contains(id)) { - _newsItems.add(item); - if (id != null) existingIds.add(id); - } + setState(() { + final existingIds = _newsItems.map((e) => e.id).where((id) => id.isNotEmpty).toSet(); + for (final item in fetched) { + if (item.id.isEmpty || !existingIds.contains(item.id)) { + _newsItems.add(item); + if (item.id.isNotEmpty) existingIds.add(item.id); } + } - // Re-sort strictly by publication timestamp descending (newest articles at the top) - _newsItems.sort((a, b) { - final dtA = _parseDateTime(a['publishedAt'] ?? a['PublishedAt'] ?? a['scrapedAt'] ?? a['ScrapedAt']); - final dtB = _parseDateTime(b['publishedAt'] ?? b['PublishedAt'] ?? b['scrapedAt'] ?? b['ScrapedAt']); - return dtB.compareTo(dtA); - }); + _newsItems.sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); - _currentPage++; - if (fetched.length < _pageSize) { - _hasMore = false; - } - }); - } + _currentPage++; + if (fetched.length < _pageSize) { + _hasMore = false; + } + }); } catch (_) { - // Handle error visually if necessary, currently silent fallback } finally { setState(() => _isLoading = false); } diff --git a/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart b/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart index 71c085b..7afae4c 100644 --- a/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart +++ b/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart @@ -3,8 +3,8 @@ import 'package:url_launcher/url_launcher.dart'; import '../models/news_article_model.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/status_badge.dart'; +import 'finbert_sentiment_tab.dart'; -/// Two-Tab Dialog for Article details and real FinBERT Sentiment Analysis. class ArticleSentimentDialog extends StatefulWidget { final NewsArticleModel articleData; @@ -32,17 +32,6 @@ class _ArticleSentimentDialogState extends State with Si super.dispose(); } - String? _findString(List keys) { - final artMap = widget.articleData.toJson(); - for (final k in keys) { - if (artMap.containsKey(k) && artMap[k] != null) { - final val = artMap[k].toString().trim(); - if (val.isNotEmpty) return val; - } - } - return null; - } - void _openOriginalSource() async { final urlStr = widget.articleData.sourceUrl; if (urlStr.isNotEmpty) { @@ -53,364 +42,160 @@ class _ArticleSentimentDialogState extends State with Si } } - Map? get _realSentimentData { - if (widget.articleData.finbertResult != null) { - return {'finbert_result': widget.articleData.finbertResult!.toJson()}; - } - return null; - } - - bool get _isLoadingSentiment => false; - @override Widget build(BuildContext context) { - final title = _findString(['Title', 'title']) ?? 'Nachrichtenartikel'; - final author = _findString(['Author', 'author', 'Source', 'source']) ?? 'Finlytic News'; - final summary = _findString(['Summary', 'summary']); - final sourceUrl = _findString(['SourceUrl', 'sourceUrl']); - final contentRaw = _findString(['ContentRaw', 'contentRaw', 'content', 'Text', 'text']); - final publishedAt = _findString(['PublishedAt', 'publishedAt', 'ScrapedAt', 'scrapedAt']) ?? ''; - final status = _findString(['Status', 'status']) ?? 'Completed'; - final rawSentiment = _findString(['sentiment', 'Sentiment', 'sentimentLabel', 'SentimentLabel']); + final article = widget.articleData; + final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel'; + final author = article.author.isNotEmpty ? article.author : 'Finlytic News'; + final summary = article.summary; + final sourceUrl = article.sourceUrl; + final contentRaw = article.contentRaw; + final publishedAt = "${article.publishedAt.day}.${article.publishedAt.month}.${article.publishedAt.year}"; + final status = article.status.isNotEmpty ? article.status : 'Completed'; + final rawSentiment = article.sentiment; - final articleMap = widget.articleData.toJson(); - final Map articleObj = articleMap.containsKey('article') && articleMap['article'] is Map - ? Map.from(articleMap['article']) - : articleMap; - final matchedAssetsRaw = articleObj['MatchedAssets'] ?? articleObj['matchedAssets'] ?? articleMap['MatchedAssets'] ?? articleMap['matchedAssets']; - final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : []; + final compoundScore = article.finbertResult?.score ?? article.sentimentScore; - final double compoundScore = widget.articleData.finbertResult?.score ?? widget.articleData.sentimentScore; - final double confidenceScore = widget.articleData.confidence; - final String label = (widget.articleData.finbertResult?.label ?? widget.articleData.sentiment).toString().toUpperCase(); - - final double posRatio = widget.articleData.finbertResult?.positiveProbability ?? 0.0; - final double neuRatio = widget.articleData.finbertResult?.neutralProbability ?? 0.0; - final double negRatio = widget.articleData.finbertResult?.negativeProbability ?? 0.0; - - final String aiText = widget.articleData.finbertResult?.summarySnippet ?? summary ?? 'FinBERT Sentiment-Analyse verarbeitet.'; - - - final Widget listBadge = rawSentiment != null + final Widget listBadge = rawSentiment.isNotEmpty ? StatusBadge.sentiment(rawSentiment.toUpperCase(), score: compoundScore) : StatusBadge( label: status.toUpperCase(), - color: status.toLowerCase().contains('analyz') || status.toLowerCase().contains('klassifi') - ? AppTheme.primaryEmerald - : AppTheme.accentCyan, + color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan, ); return Dialog( - insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), - child: Container( - width: 700, - height: 620, - padding: const EdgeInsets.all(20), - child: Column( - children: [ - TabBar( - controller: _tabController, - indicatorColor: AppTheme.primaryEmerald, - labelColor: AppTheme.primaryEmerald, - unselectedLabelColor: AppTheme.textMuted, - tabs: const [ - Tab(icon: Icon(Icons.article_outlined), text: 'Artikel & Volltext'), - Tab(icon: Icon(Icons.psychology_outlined), text: 'Sentiment-Analyse (FinBERT)'), - ], - ), - const SizedBox(height: 14), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - // Tab 1: Artikel & Volltext - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.3)), - ), - if (sourceUrl != null && sourceUrl.isNotEmpty) - IconButton( - icon: Icon(Icons.open_in_new, color: AppTheme.primaryEmerald, size: 22), - tooltip: 'Originalquelle lesen', - onPressed: _openOriginalSource, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - '$author ${publishedAt.isNotEmpty ? "• $publishedAt" : ""}', - style: TextStyle(color: AppTheme.textMuted, fontSize: 12), - ), - ), - listBadge, - ], - ), - if (matchedAssets.isNotEmpty) ...[ - const SizedBox(height: 12), - Text('Verknüpfte Wertpapiere (Matched Assets):', style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Wrap( - spacing: 6, - runSpacing: 6, - children: matchedAssets.map((a) { - final name = a['Name'] ?? a['name'] ?? a['Isin'] ?? a['isin'] ?? 'Asset'; - final isin = a['Isin'] ?? a['isin'] ?? ''; - return Chip( - label: Text( - isin.toString().isNotEmpty && name.toString() != isin.toString() - ? '${name.toString()} ($isin)' - : name.toString(), - style: TextStyle(fontSize: 11, color: AppTheme.accentCyan), - ), - backgroundColor: AppTheme.glassSurface, - side: BorderSide(color: AppTheme.glassBorder), - padding: EdgeInsets.zero, - ); - }).toList(), - ), - ], - Divider(height: 24, color: AppTheme.glassBorder), - if (summary != null && summary.isNotEmpty) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.glassSurface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.glassBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.accentCyan, fontSize: 12)), - const SizedBox(height: 4), - Text(summary, style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textPrimary)), - ], - ), - ), - const SizedBox(height: 16), - ], - const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - const SizedBox(height: 8), - Text( - contentRaw ?? summary ?? 'Kein Volltext verfügbar.', - style: TextStyle(fontSize: 13.5, height: 1.6, color: AppTheme.textSecondary), - ), - ], - ), - ), - // Tab 2: Real Sentiment Analysis (FinBERT) - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('FinBERT KI Klassifizierung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - if (_realSentimentData != null) StatusBadge.sentiment(label, score: compoundScore), - ], - ), - const SizedBox(height: 16), - if (_isLoadingSentiment) - Padding( - padding: const EdgeInsets.symmetric(vertical: 40), - child: Center( - child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2), - ), - ) - else if (_realSentimentData == null) - Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 20), - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppTheme.glassSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.glassBorder), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 44), - const SizedBox(height: 12), - const Text( - 'Keine Sentiment-Analyse vorhanden', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Für diesen Artikel liegt aktuell noch keine FinBERT-Sentiment-Analyse vor.', - style: TextStyle(fontSize: 13, color: AppTheme.textMuted, height: 1.4), - textAlign: TextAlign.center, - ), - ], - ), - ) - else ...[ - Row( - children: [ - SentimentMetricCard( - title: 'Compound Score', - valueText: '${compoundScore > 0 ? "+" : ""}${compoundScore.toStringAsFixed(2)}', - tooltipText: 'Der Compound-Score misst die aggregierte Gesamtausrichtung der Nachricht auf einer Skala von -1.00 (sehr negativ) bis +1.00 (sehr positiv). Werte ab +0.15 gelten als positiv.', - accentColor: compoundScore > 0.15 - ? AppTheme.primaryEmerald - : (compoundScore < -0.15 ? AppTheme.accentRed : AppTheme.accentCyan), - ), - const SizedBox(width: 12), - SentimentMetricCard( - title: 'KI-Confidence', - valueText: '${(confidenceScore * 100).toInt()}%', - tooltipText: 'Die Confidence gibt die statistische Wahrscheinlichkeit (0% bis 100%) an, mit welcher die FinBERT KI ihre Stimmungszuordnung berechnet hat.', - accentColor: AppTheme.accentCyan, - progressValue: confidenceScore, - ), - ], - ), - const SizedBox(height: 16), - Text('Echte FinBERT Softmax-Verteilung:', style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - ArticleSentimentMeterBar(label: 'Positiv', ratio: posRatio, color: AppTheme.primaryEmerald), - const SizedBox(height: 6), - ArticleSentimentMeterBar(label: 'Neutral', ratio: neuRatio, color: AppTheme.accentCyan), - const SizedBox(height: 6), - ArticleSentimentMeterBar(label: 'Negativ', ratio: negRatio, color: AppTheme.accentRed), - Divider(height: 24, color: AppTheme.glassBorder), - const Text('KI-Zusammenfassung & Auswirkung:', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text( - aiText, - style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textSecondary), - ), - ], - ], - ), - ), - ], - ), - ), - ], - ), + backgroundColor: AppTheme.cardSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppTheme.glassBorder), ), - ); - } -} - -/// Extracted metric card component with interactive info explanation tooltips. -class SentimentMetricCard extends StatelessWidget { - final String title; - final String valueText; - final String tooltipText; - final Color accentColor; - final double? progressValue; - - const SentimentMetricCard({ - super.key, - required this.title, - required this.valueText, - required this.tooltipText, - required this.accentColor, - this.progressValue, - }); - - @override - Widget build(BuildContext context) { - return Expanded( child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.glassSurface, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.glassBorder), - ), + width: 650, + height: 600, + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: Text( - title, - style: TextStyle(fontSize: 12, color: AppTheme.textMuted, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Quelle: $author • $publishedAt', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + ], ), ), - Tooltip( - message: tooltipText, - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - color: const Color(0xFF1E2130), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.glassBorder), - ), - textStyle: const TextStyle(color: Colors.white, fontSize: 12, height: 1.3), - child: Icon(Icons.info_outline, size: 16, color: AppTheme.accentCyan), + const SizedBox(width: 8), + listBadge, + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, color: Colors.white70), ), ], ), - const SizedBox(height: 6), - Text( - valueText, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: accentColor), - ), - if (progressValue != null) ...[ - const SizedBox(height: 6), - LinearProgressIndicator( - value: progressValue!.clamp(0.0, 1.0), - backgroundColor: AppTheme.glassBorder, - color: accentColor, - minHeight: 6, + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.glassBorder), ), - ], + child: TabBar( + controller: _tabController, + indicatorColor: AppTheme.primaryEmerald, + labelColor: AppTheme.primaryEmerald, + unselectedLabelColor: AppTheme.textMuted, + indicatorSize: TabBarIndicatorSize.tab, + tabs: const [ + Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel-Inhalt'), + Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT Sentiment'), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary.isNotEmpty) ...[ + const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(8), + ), + child: Text(summary, style: const TextStyle(color: Colors.white70, fontSize: 13)), + ), + const SizedBox(height: 12), + ], + if (article.matchedAssets.isNotEmpty) ...[ + const Text('Zugeordnete Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + children: article.matchedAssets.map((asset) { + return Chip( + label: Text('${asset.symbol} (${asset.isin})', style: const TextStyle(fontSize: 11, color: Colors.white)), + backgroundColor: AppTheme.glassSurface, + side: BorderSide(color: AppTheme.glassBorder), + ); + }).toList(), + ), + const SizedBox(height: 12), + ], + const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), + const SizedBox(height: 6), + Text( + contentRaw.isNotEmpty ? contentRaw : 'Kein vollständiger Text verfügbar.', + style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4), + ), + ], + ), + ), + FinbertSentimentTab(article: article), + ], + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (sourceUrl.isNotEmpty) + TextButton.icon( + onPressed: _openOriginalSource, + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('Originalquelle im Browser öffnen'), + style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan), + ) + else + const SizedBox.shrink(), + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black), + child: const Text('Schließen'), + ), + ], + ), ], ), ), ); } } - -/// Extracted sub-component for displaying sentiment probability bars. -class ArticleSentimentMeterBar extends StatelessWidget { - final String label; - final double ratio; - final Color color; - - const ArticleSentimentMeterBar({ - super.key, - required this.label, - required this.ratio, - required this.color, - }); - - @override - Widget build(BuildContext context) { - final clampedRatio = ratio.clamp(0.0, 1.0); - return Row( - children: [ - SizedBox(width: 60, child: Text(label, style: const TextStyle(fontSize: 12))), - Expanded( - child: LinearProgressIndicator( - value: clampedRatio, - backgroundColor: AppTheme.glassSurface, - color: color, - minHeight: 10, - ), - ), - const SizedBox(width: 10), - Text('${(clampedRatio * 100).toInt()}%', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)), - ], - ); - } -} diff --git a/FinlyticApp/lib/features/news/widgets/finbert_sentiment_tab.dart b/FinlyticApp/lib/features/news/widgets/finbert_sentiment_tab.dart new file mode 100644 index 0000000..1f555d6 --- /dev/null +++ b/FinlyticApp/lib/features/news/widgets/finbert_sentiment_tab.dart @@ -0,0 +1,117 @@ +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/news_article_model.dart'; + +class FinbertSentimentTab extends StatelessWidget { + final NewsArticleModel article; + + const FinbertSentimentTab({super.key, required this.article}); + + @override + Widget build(BuildContext context) { + final finbert = article.finbertResult; + final double compoundScore = finbert?.score ?? article.sentimentScore; + final double confidenceScore = article.confidence; + final String label = (finbert?.label ?? article.sentiment).toUpperCase(); + + final double posRatio = finbert?.positiveProbability ?? (label == 'POSITIVE' ? 0.8 : 0.1); + final double neuRatio = finbert?.neutralProbability ?? (label == 'NEUTRAL' ? 0.8 : 0.1); + final double negRatio = finbert?.negativeProbability ?? (label == 'NEGATIVE' ? 0.8 : 0.1); + + final String aiText = finbert?.summarySnippet ?? (article.summary.isNotEmpty ? article.summary : 'FinBERT Sentiment-Analyse verarbeitet.'); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.psychology, color: AppTheme.accentCyan, size: 24), + const SizedBox(width: 8), + const Text('FinBERT NLP Modell', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)), + ], + ), + StatusBadge.sentiment(label.isNotEmpty ? label : 'NEUTRAL', score: compoundScore), + ], + ), + const SizedBox(height: 16), + GlassContainer( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Sentiment Verteilung (Wahrscheinlichkeiten):', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), + const SizedBox(height: 12), + _buildProbBar('Positiv', posRatio, AppTheme.primaryEmerald), + const SizedBox(height: 8), + _buildProbBar('Neutral', neuRatio, Colors.amber), + const SizedBox(height: 8), + _buildProbBar('Negativ', negRatio, AppTheme.accentRed), + ], + ), + ), + const SizedBox(height: 16), + GlassContainer( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Konfidenz-Score:', style: TextStyle(color: Colors.white70, fontSize: 13)), + Text('${(confidenceScore * 100).toStringAsFixed(1)}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 14)), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Compound Sentiment-Wert:', style: TextStyle(color: Colors.white70, fontSize: 13)), + Text(compoundScore.toStringAsFixed(2), style: TextStyle(color: compoundScore >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 14)), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + const Text('KI-Zusammenfassung & Relevanz:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)), + const SizedBox(height: 8), + Text(aiText, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)), + ], + ), + ); + } + + Widget _buildProbBar(String label, double ratio, Color color) { + final pct = (ratio * 100).clamp(0.0, 100.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 12)), + Text('${pct.toStringAsFixed(1)}%', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ratio.clamp(0.0, 1.0), + backgroundColor: Colors.white.withValues(alpha: 0.08), + valueColor: AlwaysStoppedAnimation(color), + minHeight: 6, + ), + ), + ], + ); + } +} diff --git a/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart b/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart new file mode 100644 index 0000000..c82b3f0 --- /dev/null +++ b/FinlyticApp/lib/features/trades/models/close_trade_request_dto.dart @@ -0,0 +1,12 @@ +/// Typed DTO for requesting a trade exit/close. +class CloseTradeRequestDto { + final double userExitPrice; + + const CloseTradeRequestDto({required this.userExitPrice}); + + Map toJson() { + return { + 'userExitPrice': userExitPrice, + }; + } +} diff --git a/FinlyticApp/lib/features/trades/models/trade_model.dart b/FinlyticApp/lib/features/trades/models/trade_model.dart index 5c64af4..b6d9782 100644 --- a/FinlyticApp/lib/features/trades/models/trade_model.dart +++ b/FinlyticApp/lib/features/trades/models/trade_model.dart @@ -93,6 +93,8 @@ class TradeModel extends Equatable { return entryPrice; } + double get actualExitPrice => currentPrice; + double get calculatedPnlAbs { if (isClosed && pnlAbsolute != 0) return pnlAbsolute; final curr = currentPrice; diff --git a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart index 7af3e4b..02ccea3 100644 --- a/FinlyticApp/lib/features/trades/repositories/trade_repository.dart +++ b/FinlyticApp/lib/features/trades/repositories/trade_repository.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'package:finlytic_app/core/network/api_client.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'; class TradeRepository { final ApiClient apiClient; - TradeRepository({required this.apiClient}); + const TradeRepository({required this.apiClient}); Future> fetchTrades({String? isin, String? status}) async { try { @@ -24,8 +25,7 @@ class TradeRepository { } return []; } catch (e) { - print('Error fetching trades: $e'); - throw Exception('Trades konnten nicht geladen werden'); + throw Exception('Trades konnten nicht geladen werden: $e'); } } @@ -36,9 +36,15 @@ class TradeRepository { } } - Future closeTrade(String id, {double? exitPrice}) async { - final body = exitPrice != null ? {'userExitPrice': exitPrice} : null; - final response = await apiClient.post('/api/v1/user/trades/$id/close', data: body); + 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'); + } + } + + Future closeTrade(String id, {CloseTradeRequestDto? dto}) async { + final response = await apiClient.post('/api/v1/user/trades/$id/close', data: dto?.toJson()); if (response.statusCode != 200) { throw Exception('Trade konnte nicht geschlossen werden'); } diff --git a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart index 5b7db04..3138909 100644 --- a/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart +++ b/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart @@ -3,9 +3,7 @@ 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/widgets/glass_container.dart'; import '../../auth/bloc/auth_bloc.dart'; - import '../bloc/trade_bloc.dart'; import '../bloc/trade_event.dart'; import '../bloc/trade_state.dart'; @@ -16,6 +14,7 @@ import '../widgets/trade_card.dart'; import '../widgets/proposed_auto_trades_card.dart'; import '../widgets/trade_acceptance_dialog.dart'; import '../widgets/trade_execution_dialog.dart'; +import '../widgets/trade_performance_bar.dart'; class TradesFeedScreen extends StatelessWidget { final ApiClient apiClient; @@ -46,7 +45,7 @@ class _TradesFeedScreenContent extends StatefulWidget { } class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { - String _selectedFilter = 'Offen'; // 'Alle', 'Offen', 'Vorschläge', 'Geschlossen' + String _selectedFilter = 'Offen'; String _searchQuery = ''; final TextEditingController _searchCtrl = TextEditingController(); @@ -96,7 +95,6 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Title & Reload Row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -115,25 +113,18 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { ], ), IconButton( - onPressed: () { - context.read().add(const FetchTrades()); - }, + onPressed: () => context.read().add(const FetchTrades()), icon: const Icon(Icons.refresh, color: Colors.white70), tooltip: 'Trades Aktualisieren', ), ], ), - const SizedBox(height: 16), - - // Main Content Body Expanded( child: BlocBuilder( builder: (context, state) { if (state is TradeLoading) { - return Center( - child: CircularProgressIndicator(color: AppTheme.primaryEmerald), - ); + return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)); } if (state is TradeError) { @@ -157,21 +148,11 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { if (state is TradeLoaded) { final allTrades = state.trades; - - // Separate proposals, active, closed, and rejected 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(); - // Performance Header Calculations - final totalOpenPnlAbs = activeTrades.fold(0, (sum, t) => sum + t.calculatedPnlAbs); - final isPnlPos = totalOpenPnlAbs >= 0; - final winRatePct = allTrades.isNotEmpty - ? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100) - : 0.0; - - // Filter list according to tab & search List filteredList = allTrades; if (_selectedFilter == 'Alle') { filteredList = allTrades.where((t) => !t.isProposed).toList(); @@ -195,42 +176,21 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { return ListView( children: [ - // 1. Performance Overview Bar - GlassContainer( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - margin: const EdgeInsets.only(bottom: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _summaryStat('Offene Trades', '${activeTrades.length}', AppTheme.primaryEmerald), - _summaryStat( - 'Offenes PnL', - '${isPnlPos ? '+' : ''}${totalOpenPnlAbs.toStringAsFixed(2)} €', - isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed, - ), - _summaryStat('Trefferquote', '${winRatePct.toStringAsFixed(0)}%', Colors.amber), - _summaryStat('Auto-Vorschläge', '${proposals.length}', AppTheme.accentCyan), - ], - ), + TradePerformanceBar( + activeTrades: activeTrades, + allTrades: allTrades, + proposals: proposals, ), - - // 2. Featured Card: Auto KI Trade Proposals ProposedAutoTradesCard( proposals: proposals, onAcceptProposal: (trade) => _handleAcceptProposal(context, trade), ), - - // 3. Search & Filter Section Row( children: [ Expanded( child: TextField( controller: _searchCtrl, - onChanged: (val) { - setState(() { - _searchQuery = val; - }); - }, + onChanged: (val) => setState(() => _searchQuery = val), style: const TextStyle(color: Colors.white, fontSize: 13), decoration: InputDecoration( hintText: 'Suche nach Symbol, ISIN oder Name...', @@ -239,23 +199,14 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { 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)), - ), + 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), - - // Filter Chips Row SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -268,10 +219,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { ], ), ), - const SizedBox(height: 16), - - // 4. Trades List if (filteredList.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -280,10 +228,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { children: [ Icon(Icons.inbox, size: 40, color: AppTheme.textMuted), const SizedBox(height: 8), - Text( - 'Keine Trades in der Kategorie "$_selectedFilter" gefunden.', - style: TextStyle(color: AppTheme.textMuted, fontSize: 13), - ), + Text('Keine Trades in der Kategorie "$_selectedFilter" gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), ], ), ), @@ -324,33 +269,17 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { ); } - Widget _summaryStat(String label, String value, Color valColor) { - return Column( - children: [ - Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)), - const SizedBox(height: 3), - Text(value, style: TextStyle(color: valColor, fontWeight: FontWeight.bold, fontSize: 15)), - ], - ); - } - Widget _filterChip(String label, int count) { final isSelected = _selectedFilter == label; return GestureDetector( - onTap: () { - setState(() { - _selectedFilter = label; - }); - }, + onTap: () => setState(() => _selectedFilter = label), child: Container( margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.12), - ), + border: Border.all(color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.12)), ), child: Row( children: [ diff --git a/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart b/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart new file mode 100644 index 0000000..434842a --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/trade_detail_content.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../models/trade_model.dart'; + +class TradeDetailContent extends StatelessWidget { + final TradeModel trade; + + const TradeDetailContent({super.key, required this.trade}); + + @override + Widget build(BuildContext context) { + final pnlAbs = trade.calculatedPnlAbs; + final pnlPct = trade.calculatedPnlPct; + final isPnlPos = pnlAbs >= 0; + final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed; + final currPrice = trade.effectiveCurrentPrice; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Row( + 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)} €' : '-'), + Colors.white, + ), + _metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)} €', AppTheme.accentCyan), + _metricItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed), + _metricItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald), + ], + ), + ), + if (trade.isActive || trade.isClosed) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: pnlColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: pnlColor.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)), + Text( + '${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)} € (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)', + style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + 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(14), + decoration: BoxDecoration( + color: AppTheme.primaryEmerald.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + 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: 18), + ], + 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(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Text(trade.technicalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)), + ), + const SizedBox(height: 18), + ], + if (trade.fundamentalRationale.isNotEmpty) ...[ + _sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.03), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Text(trade.fundamentalRationale, style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4)), + ), + const SizedBox(height: 18), + ], + 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(14), + decoration: BoxDecoration( + color: AppTheme.accentRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)), + ), + child: Text(trade.riskWarning, style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4)), + ), + const SizedBox(height: 18), + ], + _sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.02), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + 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(0)}x'), + if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)} €'), + ], + ), + ), + ], + ); + } + + Widget _sectionTitle(IconData icon, String title, Color color) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ); + } + + Widget _metricItem(String label, String value, Color color) { + return Column( + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + const SizedBox(height: 4), + Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ); + } + + Widget _paramRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 12)), + Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + ], + ), + ); + } +} diff --git a/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart b/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart index b458d22..83d006e 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_detail_modal.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../core/theme/app_theme.dart'; import '../models/trade_model.dart'; +import 'trade_detail_content.dart'; class TradeDetailModal extends StatelessWidget { final TradeModel trade; @@ -36,11 +37,6 @@ class TradeDetailModal extends StatelessWidget { Widget build(BuildContext context) { final isBuy = trade.signalType == 'BUY'; final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed; - final pnlAbs = trade.calculatedPnlAbs; - final pnlPct = trade.calculatedPnlPct; - final isPnlPos = pnlAbs >= 0; - final pnlColor = isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed; - final currPrice = trade.effectiveCurrentPrice; return Container( constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), @@ -60,7 +56,6 @@ class TradeDetailModal extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Handle Bar Container( margin: const EdgeInsets.symmetric(vertical: 12), width: 40, @@ -70,8 +65,6 @@ class TradeDetailModal extends StatelessWidget { borderRadius: BorderRadius.circular(2), ), ), - - // Modal Header Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( @@ -134,172 +127,14 @@ class TradeDetailModal extends StatelessWidget { ], ), ), - const SizedBox(height: 16), const Divider(color: Colors.white10, height: 1), - - // Scrollable Content Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Price Grid - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Row( - 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)} €' : '-'), - Colors.white - ), - _metricItem('Live-Kurs', '${currPrice.toStringAsFixed(2)} €', AppTheme.accentCyan), - _metricItem('Stop-Loss', '${trade.stopLoss.toStringAsFixed(2)} €', AppTheme.accentRed), - _metricItem('Take-Profit', '${trade.takeProfit.toStringAsFixed(2)} €', AppTheme.primaryEmerald), - ], - ), - ), - - if (trade.isActive || trade.isClosed) ...[ - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: pnlColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: pnlColor.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Aktueller PnL:', style: TextStyle(color: Colors.white70, fontSize: 13)), - Text( - '${isPnlPos ? '+' : ''}${pnlAbs.toStringAsFixed(2)} € (${isPnlPos ? '+' : ''}${pnlPct.toStringAsFixed(2)}%)', - style: TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15), - ), - ], - ), - ), - ], - - const SizedBox(height: 20), - - // AI Reasoning Section - 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(14), - decoration: BoxDecoration( - color: AppTheme.primaryEmerald.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - 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: 18), - ], - - // Technical Rationale Section - 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(14), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Text( - trade.technicalRationale, - style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4), - ), - ), - const SizedBox(height: 18), - ], - - // Fundamental Rationale Section - if (trade.fundamentalRationale.isNotEmpty) ...[ - _sectionTitle(Icons.account_balance, 'Fundamentale Bewertung', Colors.purpleAccent), - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.03), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Text( - trade.fundamentalRationale, - style: TextStyle(color: AppTheme.textMuted, fontSize: 13, height: 1.4), - ), - ), - const SizedBox(height: 18), - ], - - // Risk Warning Section - 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(14), - decoration: BoxDecoration( - color: AppTheme.accentRed.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.3)), - ), - child: Text( - trade.riskWarning, - style: TextStyle(color: AppTheme.accentRed, fontSize: 12, height: 1.4), - ), - ), - const SizedBox(height: 18), - ], - - // Trade Parameters Grid - _sectionTitle(Icons.tune, 'Trade-Parameter & Instrument', Colors.white70), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.02), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - 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(0)}x'), - if (trade.positionSize > 0) _paramRow('Positionsgröße:', '${trade.positionSize.toStringAsFixed(2)} €'), - ], - ), - ), - ], - ), + child: TradeDetailContent(trade: trade), ), ), - - // Footer Action Bar Padding( padding: const EdgeInsets.all(16), child: Row( @@ -363,40 +198,4 @@ class TradeDetailModal extends StatelessWidget { ), ); } - - Widget _sectionTitle(IconData icon, String title, Color color) { - return Row( - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 8), - Text( - title, - style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13), - ), - ], - ); - } - - Widget _metricItem(String label, String value, Color color) { - return Column( - children: [ - Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), - const SizedBox(height: 4), - Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), - ], - ); - } - - Widget _paramRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 12)), - Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), - ], - ), - ); - } } 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 new file mode 100644 index 0000000..066412c --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_ai_plan_card.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../models/trade_model.dart'; + +class TradeExecutionAiPlanCard extends StatelessWidget { + final TradeModel trade; + + const TradeExecutionAiPlanCard({super.key, required this.trade}); + + static String _fmt(dynamic val) { + if (val == null) return '0.00'; + if (val is double) { + if (val > 100) return val.toStringAsFixed(1); + return val.toStringAsFixed(2); + } + return val.toString(); + } + + static Widget _buildTradeStat(String label, String value, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)), + const SizedBox(height: 2), + Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ); + } + + 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 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; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.glassSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.glassBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + 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)), + ), + ], + ), + 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)), + ], + ), + 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), + ], + ), + 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), + _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_dialog.dart b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart index 9bc16f2..94d6b0b 100644 --- a/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart +++ b/FinlyticApp/lib/features/trades/widgets/trade_execution_dialog.dart @@ -1,18 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:finlytic_app/core/theme/app_theme.dart'; -import 'package:finlytic_app/core/widgets/status_badge.dart'; -import 'package:finlytic_app/core/network/api_client.dart'; -import '../../../../features/trades/models/trade_model.dart'; -import '../../../../features/trades/models/trade_acceptance_dto.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 const List _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto']; - /// Normalisiert beliebige Freitexte/Bezeichnungen auf die erlaubten Dropdown-Werte static String _normalizeInstrumentType(String raw) { final clean = raw.toLowerCase().trim(); if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) { @@ -30,21 +29,20 @@ class TradeExecutionDialog { if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) { return 'Stock'; } - return 'KnockOut'; // Fallback + 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, - }) { + 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; @@ -61,7 +59,6 @@ class TradeExecutionDialog { final derivativeIsinController = TextEditingController(text: trade.derivativeIsin); - // Normalisierte Zuweisung verhindert den DropdownButton Assertion-Error String selectedInstrumentType = _normalizeInstrumentType( trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut', ); @@ -117,11 +114,7 @@ class TradeExecutionDialog { 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, - ), + const SnackBar(content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating), ); return; } @@ -129,43 +122,36 @@ class TradeExecutionDialog { setModalState(() => isFetchingDerivativePrice = true); try { final apiClient = context.read(); - final res = await apiClient.get('/api/v1/assets/$cleanIsin/technicals?forceRefresh=true'); - if (res.statusCode == 200 && res.data != null) { - final Map data = res.data; - double? fetchedPrice; - if (data['candles'] is List && (data['candles'] as List).isNotEmpty) { - fetchedPrice = ((data['candles'] as List).last['close'] as num?)?.toDouble(); - } else if (data['currentPrice'] != null) { - fetchedPrice = (data['currentPrice'] as num?)?.toDouble(); - } + final assetRepo = AssetRepository(apiClient: apiClient); + final technicals = await assetRepo.getAssetTechnical(cleanIsin, true); - 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, - ), - ); + 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 via tr_GetPrice: $e'), - backgroundColor: AppTheme.accentRed, - behavior: SnackBarBehavior.floating, - ), + SnackBar(content: Text('Fehler beim Abrufen des Kurses für $cleanIsin: $e'), backgroundColor: AppTheme.accentRed, behavior: SnackBarBehavior.floating), ); } finally { setModalState(() => isFetchingDerivativePrice = false); @@ -185,17 +171,11 @@ class TradeExecutionDialog { selectedInstrumentType.toLowerCase().contains('option') || selectedInstrumentType.toLowerCase().contains('cfd'); - // Absicherung gegen Assertion-Errors: Stellt sicher, dass der selektierte Wert in der Liste existiert - final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType) - ? selectedInstrumentType - : 'KnockOut'; + final safeInstrumentValue = _allowedInstruments.contains(selectedInstrumentType) ? selectedInstrumentType : 'KnockOut'; return AlertDialog( backgroundColor: AppTheme.cardSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppTheme.glassBorder), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)), title: Row( children: [ Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22), @@ -215,142 +195,16 @@ class TradeExecutionDialog { 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), - ), + Text('Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), const SizedBox(height: 12), - - Builder( - builder: (context) { - final signal = trade.signalType.toUpperCase(); - final isLong = signal == 'BUY' || signal == 'LONG'; - 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; - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.glassSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.glassBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - 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)), - ), - ], - ), - 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)), - ], - ), - 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), - ], - ), - 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), - _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), - ], - ), - ], - ], - ), - ); - }, - ), + 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), - - // Instrument-Type Dropdown mit abgesichertem Value DropdownButtonFormField( - value: safeInstrumentValue, + initialValue: safeInstrumentValue, dropdownColor: AppTheme.cardSurface, - decoration: const InputDecoration( - labelText: 'Finanzinstrument Typ', - contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), - ), + decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)), items: const [ DropdownMenuItem(value: 'Stock', child: Text('Aktie / ETF (Direktinvestment)', style: TextStyle(color: Colors.white, fontSize: 13))), DropdownMenuItem(value: 'KnockOut', child: Text('Knock-Out Zertifikat', style: TextStyle(color: Colors.white, fontSize: 13))), @@ -359,49 +213,32 @@ class TradeExecutionDialog { DropdownMenuItem(value: 'Crypto', child: Text('Krypto', style: TextStyle(color: Colors.white, fontSize: 13))), ], onChanged: (val) { - if (val != null) { - setModalState(() { - selectedInstrumentType = val; - }); - } + if (val != null) setModalState(() => selectedInstrumentType = val); }, ), const SizedBox(height: 10), - if (isKnockout) ...[ Row( - crossAxisAlignment: CrossAxisAlignment.center, 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), - ), + 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), + 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), - ), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), ), ], ), const SizedBox(height: 10), ], - Row( children: [ Expanded( @@ -422,7 +259,6 @@ class TradeExecutionDialog { ], ), const SizedBox(height: 10), - Row( children: [ Expanded( @@ -443,7 +279,6 @@ class TradeExecutionDialog { ], ), const SizedBox(height: 10), - Row( children: [ Expanded( @@ -464,7 +299,6 @@ class TradeExecutionDialog { ], ), const SizedBox(height: 10), - Row( children: [ Expanded( @@ -501,9 +335,7 @@ class TradeExecutionDialog { }, 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), - ), + style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed)), ), if (!isActive && onReject != null) const SizedBox(width: 8), ElevatedButton.icon( @@ -526,38 +358,4 @@ class TradeExecutionDialog { }, ); } - - static String _fmt(dynamic val) { - if (val == null) return '0.00'; - if (val is double) { - if (val > 100) return val.toStringAsFixed(1); - return val.toStringAsFixed(2); - } - return val.toString(); - } - - static Widget _buildTradeStat(String label, String value, Color color) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)), - const SizedBox(height: 2), - Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), - ], - ); - } - - 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)), - ], - ), - ); - } } \ 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 new file mode 100644 index 0000000..ba90ca0 --- /dev/null +++ b/FinlyticApp/lib/features/trades/widgets/trade_performance_bar.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/glass_container.dart'; +import '../models/trade_model.dart'; + +class TradePerformanceBar extends StatelessWidget { + final List activeTrades; + final List allTrades; + final List proposals; + + const TradePerformanceBar({ + super.key, + required this.activeTrades, + required this.allTrades, + required this.proposals, + }); + + Widget _summaryStat(String label, String value, Color color) { + return Column( + children: [ + Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), + const SizedBox(height: 4), + Text(value, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 15)), + ], + ); + } + + @override + Widget build(BuildContext context) { + final totalOpenPnlAbs = activeTrades.fold(0, (sum, t) => sum + t.calculatedPnlAbs); + final isPnlPos = totalOpenPnlAbs >= 0; + final winRatePct = allTrades.isNotEmpty + ? (allTrades.where((t) => t.pnlAbsolute >= 0).length / allTrades.length * 100) + : 0.0; + + return GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + margin: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _summaryStat('Offene Trades', '${activeTrades.length}', AppTheme.primaryEmerald), + _summaryStat( + 'Offenes PnL', + '${isPnlPos ? '+' : ''}${totalOpenPnlAbs.toStringAsFixed(2)} €', + isPnlPos ? AppTheme.primaryEmerald : AppTheme.accentRed, + ), + _summaryStat('Trefferquote', '${winRatePct.toStringAsFixed(0)}%', Colors.amber), + _summaryStat('Auto-Vorschläge', '${proposals.length}', AppTheme.accentCyan), + ], + ), + ); + } +} diff --git a/FinlyticApp/pubspec.lock b/FinlyticApp/pubspec.lock index 21e6174..07eaee6 100644 --- a/FinlyticApp/pubspec.lock +++ b/FinlyticApp/pubspec.lock @@ -33,6 +33,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996" + url: "https://pub.dev" + source: hosted + version: "1.3.0" characters: dependency: transitive description: @@ -158,6 +182,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" flutter_lints: dependency: "direct dev" description: @@ -384,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -512,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -597,6 +645,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" sse: dependency: transitive description: @@ -637,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" term_glyph: dependency: transitive description: diff --git a/FinlyticApp/pubspec.yaml b/FinlyticApp/pubspec.yaml index 04c32ca..0442d46 100644 --- a/FinlyticApp/pubspec.yaml +++ b/FinlyticApp/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: cupertino_icons: ^1.0.6 url_launcher: ^6.3.2 flutter_svg: ^2.0.9 + cached_network_image: ^3.3.1 dev_dependencies: flutter_test: