feat(app): clean architecture with typed repositories, DTO models and calendar event logos

This commit is contained in:
2026-08-15 01:03:22 +02:00
parent f08fecde23
commit 15f8f7896e
34 changed files with 1435 additions and 1420 deletions
@@ -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);
},
@@ -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<String, dynamic> json) {
return ServiceSettingDto(
key: json['key']?.toString() ?? '',
value: json['value']?.toString() ?? '',
description: json['description']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
'value': value,
'description': description,
};
}
}
@@ -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<List<AdminUserModel>> 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<Map<String, List<ServiceSettingDto>>> fetchSettings() async {
final res = await apiClient.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
final map = res.data as Map<String, dynamic>;
final result = <String, List<ServiceSettingDto>>{};
map.forEach((k, v) {
if (v is List) {
result[k] = v
.whereType<Map<String, dynamic>>()
.map((item) => ServiceSettingDto.fromJson(item))
.toList();
}
});
return result;
}
return {};
}
Future<void> updateServiceSettings(String serviceName, Map<String, String> 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');
}
}
}
@@ -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<AdminBloc>().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();
}
}
@@ -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<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
}
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
late final AdminRepository _repository;
bool _isLoading = true;
bool _isSaving = false;
String _error = '';
List<dynamic> _settings = [];
List<ServiceSettingDto> _settings = [];
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
_repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient);
_fetchServiceDetails();
}
@@ -38,27 +48,22 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
Future<void> _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<String, dynamic>;
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<ServiceDetailScreen> {
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<ServiceDetailScreen> {
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();
@@ -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<bool> 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,
),
],
),
],
),
);
}
}
@@ -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<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
}
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
late final AdminRepository _repository;
String _selectedServiceKey = 'FinlyticAssets';
bool _isLoading = false;
bool _isSaving = false;
@@ -132,39 +133,34 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
},
};
bool _initialized = false;
@override
void initState() {
super.initState();
_fetchSettings();
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
final client = widget.apiClient ?? context.read<ApiClient>();
_repository = widget.repository ?? AdminRepository(apiClient: client);
_fetchSettings();
}
}
Future<void> _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<String, dynamic> data = Map<String, dynamic>.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<PipelineSettingsWidget> {
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<PipelineSettingsWidget> {
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<PipelineSettingsWidget> {
}
}
@override
Widget build(BuildContext context) {
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
@@ -228,7 +221,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Service Selection Ribbon
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -250,15 +242,16 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
),
),
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<PipelineSettingsWidget> {
}).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'(?<!^)(?=[A-Z])'), ' ')
.replaceAll('Minutes', '(Minuten)')
.replaceAll('Seconds', '(Sekunden)')
.replaceAll('Hours', '(Stunden)')
.replaceAll('Days', '(Tage)')
.replaceAll('Limit', 'Grenzwert')
.replaceAll('Period', 'Periode')
.replaceAll('Percentage', '(%)')
.replaceAll('Multiplier', 'Multiplikator');
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
class ServiceSettingsForm extends StatelessWidget {
final Map<String, TextEditingController> 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(),
),
);
}
}
@@ -67,6 +67,14 @@ class AuthRepository {
}
}
Future<bool> 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<void> logout() async {
await storageService.clearAll();
}
@@ -76,3 +84,4 @@ class RequiresPasswordChangeException implements Exception {
final String userId;
RequiresPasswordChangeException(this.userId);
}
@@ -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<ChangeInitialPasswordScree
if (_formKey.currentState?.validate() ?? false) {
setState(() => _isLoading = true);
try {
final apiClient = context.read<ApiClient>();
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
'userId': widget.userId,
'newPassword': _passwordController.text,
});
final authRepo = AuthRepository(
apiClient: context.read<ApiClient>(),
storageService: context.read<SecureStorageService>(),
);
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),
@@ -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<String, dynamic> 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<Object?> get props =>
[id, symbol, companyName, eventType, eventDate, description];
[id, symbol, companyName, eventType, eventDate, description, isin, image, ticker];
}
@@ -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(
@@ -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(
@@ -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<DiscoveryAssetModel> assets;
@@ -27,27 +28,24 @@ class DiscoveryState extends Equatable {
}
class DiscoveryCubit extends Cubit<DiscoveryState> {
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<void> 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<String, dynamic>.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));
}
}
}
@@ -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<List<DiscoveryAssetModel>> 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<String, dynamic>>()
.map((e) => DiscoveryAssetModel.fromJson(e))
.toList();
}
return [];
}
}
@@ -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<String> favoriteIsins;
@@ -39,11 +40,16 @@ class FavoritesState extends Equatable {
}
class FavoritesCubit extends Cubit<FavoritesState> {
final ApiClient apiClient;
final FavoritesRepository repository;
final SignalRService? signalRService;
StreamSubscription<Map<String, dynamic>>? _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<FavoritesState> {
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<void> 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 = <String>{};
final dedupMap = <String, FavoriteAssetModel>{};
final list = await repository.getFavorites();
final set = <String>{};
for (var item in rawList) {
final model = FavoriteAssetModel.fromJson(Map<String, dynamic>.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<FavoritesState> {
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<FavoritesState> {
}).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();
}
}
}
@@ -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<List<FavoriteAssetModel>> 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 = <String, FavoriteAssetModel>{};
for (var item in rawList) {
if (item is Map<String, dynamic>) {
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<bool> 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<bool> 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<bool> updateFavoriteTicker(String symbolOrIsin, String ticker) async {
final res = await apiClient.post('/api/v1/user/favorites/$symbolOrIsin/ticker?ticker=$ticker');
return res.statusCode == 200;
}
}
@@ -39,27 +39,29 @@ class NewsArticleModel extends Equatable {
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
List<MatchedAssetModel> 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<String, dynamic>)).toList();
assets = mList
.whereType<Map<String, dynamic>>()
.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<String, dynamic>)
: null,
matchedAssets: assets,
);
@@ -23,6 +23,8 @@ class NewsRepository {
String? symbol,
String? isin,
String? date,
String? query,
bool? hasSentiment,
}) async {
try {
final Map<String, dynamic> 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<dynamic> data = response.data;
return data.map((json) => NewsArticleModel.fromJson(json)).toList();
return data
.whereType<Map<String, dynamic>>()
.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');
}
}
@@ -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<NewsFeedScreen> createState() => _NewsFeedScreenState();
}
class _NewsFeedScreenState extends State<NewsFeedScreen> {
late final NewsRepository _repository;
final ScrollController _scrollController = ScrollController();
final List<dynamic> _newsItems = [];
final List<NewsArticleModel> _newsItems = [];
int _currentPage = 1;
static const int _pageSize = 15;
bool _isLoading = false;
@@ -34,6 +38,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
@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<NewsFeedScreen> {
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<void> _loadNews({bool refresh = false}) async {
if (_isLoading) return;
if (refresh) {
@@ -72,54 +66,32 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
setState(() => _isLoading = true);
try {
final queryParams = <String, dynamic>{
'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);
}
@@ -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<ArticleSentimentDialog> with Si
super.dispose();
}
String? _findString(List<String> 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<ArticleSentimentDialog> with Si
}
}
Map<String, dynamic>? 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<String, dynamic> articleObj = articleMap.containsKey('article') && articleMap['article'] is Map
? Map<String, dynamic>.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)),
],
);
}
}
@@ -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>(color),
minHeight: 6,
),
),
],
);
}
}
@@ -0,0 +1,12 @@
/// Typed DTO for requesting a trade exit/close.
class CloseTradeRequestDto {
final double userExitPrice;
const CloseTradeRequestDto({required this.userExitPrice});
Map<String, dynamic> toJson() {
return {
'userExitPrice': userExitPrice,
};
}
}
@@ -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;
@@ -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<List<TradeModel>> 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<void> 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<void> rejectTrade(String tradeId) async {
final response = await apiClient.post('/api/v1/user/trades/$tradeId/reject');
if (response.statusCode != 200) {
throw Exception('Trade konnte nicht abgelehnt werden');
}
}
Future<void> 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');
}
@@ -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<TradeBloc>().add(const FetchTrades());
},
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Trades Aktualisieren',
),
],
),
const SizedBox(height: 16),
// Main Content Body
Expanded(
child: BlocBuilder<TradeBloc, TradeState>(
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<double>(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<TradeModel> 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: [
@@ -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)),
],
),
);
}
}
@@ -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)),
],
),
);
}
}
@@ -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),
],
),
],
],
),
);
}
}
@@ -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<String> _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<ApiClient>();
final res = await apiClient.get('/api/v1/assets/$cleanIsin/technicals?forceRefresh=true');
if (res.statusCode == 200 && res.data != null) {
final Map<String, dynamic> 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<String>(
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)),
],
),
);
}
}
@@ -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<TradeModel> activeTrades;
final List<TradeModel> allTrades;
final List<TradeModel> 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<double>(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),
],
),
);
}
}