Compare commits

...

4 Commits

70 changed files with 4954 additions and 4673 deletions
File diff suppressed because one or more lines are too long
@@ -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(),
),
);
}
}
@@ -15,5 +15,32 @@ class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState>
emit(AssetTechnicalError(e.toString()));
}
});
on<TogglePatternFilter>((event, emit) {
if (state is AssetTechnicalLoaded) {
final current = state as AssetTechnicalLoaded;
final updated = Set<int>.from(current.disabledPatternIndices);
if (event.enabled) {
updated.remove(event.patternIndex);
} else {
updated.add(event.patternIndex);
}
emit(current.copyWith(disabledPatternIndices: updated));
}
});
on<ToggleIndicatorFilter>((event, emit) {
if (state is AssetTechnicalLoaded) {
final current = state as AssetTechnicalLoaded;
emit(current.copyWith(
showSma50: event.showSma50,
showSma200: event.showSma200,
showEma: event.showEma,
showSupertrend: event.showSupertrend,
showPatterns: event.showPatterns,
showSignals: event.showSignals,
));
}
});
}
}
@@ -1,7 +1,32 @@
abstract class AssetTechnicalEvent {}
class LoadAssetTechnical extends AssetTechnicalEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
}
class TogglePatternFilter extends AssetTechnicalEvent {
final int patternIndex;
final bool enabled;
TogglePatternFilter({required this.patternIndex, required this.enabled});
}
class ToggleIndicatorFilter extends AssetTechnicalEvent {
final bool? showSma50;
final bool? showSma200;
final bool? showEma;
final bool? showSupertrend;
final bool? showPatterns;
final bool? showSignals;
ToggleIndicatorFilter({
this.showSma50,
this.showSma200,
this.showEma,
this.showSupertrend,
this.showPatterns,
this.showSignals,
});
}
@@ -1,12 +1,55 @@
import '../../models/technical_analysis_model.dart';
abstract class AssetTechnicalState {}
class AssetTechnicalInitial extends AssetTechnicalState {}
class AssetTechnicalLoading extends AssetTechnicalState {}
class AssetTechnicalLoaded extends AssetTechnicalState {
final TechnicalAnalysisModel? data;
AssetTechnicalLoaded(this.data);
final Set<int> disabledPatternIndices;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSupertrend;
final bool showPatterns;
final bool showSignals;
AssetTechnicalLoaded(
this.data, {
this.disabledPatternIndices = const {},
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showSupertrend = true,
this.showPatterns = true,
this.showSignals = true,
});
AssetTechnicalLoaded copyWith({
TechnicalAnalysisModel? data,
Set<int>? disabledPatternIndices,
bool? showSma50,
bool? showSma200,
bool? showEma,
bool? showSupertrend,
bool? showPatterns,
bool? showSignals,
}) {
return AssetTechnicalLoaded(
data ?? this.data,
disabledPatternIndices: disabledPatternIndices ?? this.disabledPatternIndices,
showSma50: showSma50 ?? this.showSma50,
showSma200: showSma200 ?? this.showSma200,
showEma: showEma ?? this.showEma,
showSupertrend: showSupertrend ?? this.showSupertrend,
showPatterns: showPatterns ?? this.showPatterns,
showSignals: showSignals ?? this.showSignals,
);
}
}
class AssetTechnicalError extends AssetTechnicalState {
final String message;
AssetTechnicalError(this.message);
@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
final String? payment;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
this.payment,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
double? compVal;
if (json['compensation'] != null) {
compVal = (json['compensation'] as num?)?.toDouble() ?? double.tryParse(json['compensation'].toString());
}
final rawPayment = json['payment']?.toString();
if (compVal == null && rawPayment != null && rawPayment.isNotEmpty) {
compVal = double.tryParse(rawPayment);
}
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: compVal,
payment: rawPayment,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
if (age != null) 'age': age,
if (compensation != null) 'compensation': compensation,
if (payment != null) 'payment': payment,
};
}
@override
List<Object?> get props => [name, title, age, compensation, payment];
}
@@ -0,0 +1,158 @@
import 'package:equatable/equatable.dart';
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: (json['expectedRevenue'] as num?)?.toDouble() ?? (json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null),
expectedEps: (json['expectedEps'] as num?)?.toDouble() ?? (json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null),
expectedGrowthRate: (json['expectedGrowthRate'] as num?)?.toDouble() ?? (json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null),
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
if (expectedRevenue != null) 'expectedRevenue': expectedRevenue,
if (expectedEps != null) 'expectedEps': expectedEps,
if (expectedGrowthRate != null) 'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
@@ -1,4 +1,13 @@
import 'package:equatable/equatable.dart';
import 'ticker_model.dart';
import 'company_officer_model.dart';
import 'financial_statement_model.dart';
import 'forward_estimate_model.dart';
export 'ticker_model.dart';
export 'company_officer_model.dart';
export 'financial_statement_model.dart';
export 'forward_estimate_model.dart';
class FundamentalDataModel extends Equatable {
final String isin;
@@ -16,10 +25,10 @@ class FundamentalDataModel extends Equatable {
final double currentPrice;
final double dayChangeAbsolute;
final double dayChangePercent;
final double fiftyTwoWeekHigh;
final double fiftyTwoWeekLow;
final double marketCapitalization;
final double enterpriseValue;
final double? fiftyTwoWeekHigh;
final double? fiftyTwoWeekLow;
final double? marketCapitalization;
final double? enterpriseValue;
final double? peRatioTrailing;
final double? peRatioForward;
@@ -85,10 +94,10 @@ class FundamentalDataModel extends Equatable {
required this.currentPrice,
required this.dayChangeAbsolute,
required this.dayChangePercent,
required this.fiftyTwoWeekHigh,
required this.fiftyTwoWeekLow,
required this.marketCapitalization,
required this.enterpriseValue,
this.fiftyTwoWeekHigh,
this.fiftyTwoWeekLow,
this.marketCapitalization,
this.enterpriseValue,
this.peRatioTrailing,
this.peRatioForward,
this.pegRatio,
@@ -135,12 +144,6 @@ class FundamentalDataModel extends Equatable {
});
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
double? parseNullableDouble(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
@@ -171,8 +174,8 @@ class FundamentalDataModel extends Equatable {
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
: primaryTickerVal;
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal;
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString();
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal;
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString();
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
extractExchangeStr(assetMap?['primaryTicker']) ??
@@ -181,16 +184,12 @@ class FundamentalDataModel extends Equatable {
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
List<TickerModel> availableTickersList = [];
if (rawTickers is List) {
availableTickersList = rawTickers.map((t) {
if (t is Map<String, dynamic>) {
return TickerModel.fromJson(t);
} else {
return TickerModel(ticker: t.toString());
}
}).toList();
availableTickersList = rawTickers
.whereType<Map<String, dynamic>>()
.map((t) => TickerModel.fromJson(t))
.toList();
}
// Revenue & Margins Derivation
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']);
@@ -202,96 +201,52 @@ class FundamentalDataModel extends Equatable {
}
}
// Enterprise Value to Revenue
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
evToRevVal = evVal / totalRev;
}
// Event Dates (Ex-Dividend & Next Earnings)
String? exDividendDateVal = json['exDividendDate']?.toString() ?? fundMap?['exDividendDate']?.toString();
String? nextEarningsDateVal = json['nextEarningsDate']?.toString() ?? fundMap?['nextEarningsDate']?.toString();
final rawEvents = json['events'];
if (rawEvents is List && rawEvents.isNotEmpty) {
final now = DateTime.now();
final parsedEvents = <Map<String, dynamic>>[];
for (final ev in rawEvents) {
if (ev is Map<String, dynamic>) {
final dtStr = ev['date']?.toString();
final dt = dtStr != null ? DateTime.tryParse(dtStr) : null;
if (dt != null) {
parsedEvents.add({
'type': ev['type']?.toString().toUpperCase() ?? '',
'date': dt,
'dateStr': dtStr,
});
}
}
}
if (exDividendDateVal == null) {
final dividendEvents = parsedEvents.where((e) => e['type'] == 'DIVIDEND').toList()
..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
final futureDividends = dividendEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList();
if (futureDividends.isNotEmpty) {
exDividendDateVal = futureDividends.first['dateStr'] as String;
} else if (dividendEvents.isNotEmpty) {
exDividendDateVal = dividendEvents.last['dateStr'] as String;
}
}
if (nextEarningsDateVal == null) {
final earningsEvents = parsedEvents.where((e) => e['type'] == 'EARNINGS_RELEASE' || e['type'] == 'EARNINGS_CALL').toList()
..sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
final futureEarnings = earningsEvents.where((e) => (e['date'] as DateTime).isAfter(now)).toList();
if (futureEarnings.isNotEmpty) {
nextEarningsDateVal = futureEarnings.first['dateStr'] as String;
} else if (earningsEvents.isNotEmpty) {
nextEarningsDateVal = earningsEvents.last['dateStr'] as String;
}
}
}
return FundamentalDataModel(
isin: isinVal,
primaryTicker: primaryTickerVal,
ticker: tickerVal,
companyName: companyNameVal,
exchange: exchangeVal,
tradingCurrency: json['tradingCurrency']?.toString(),
tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(),
businessSummary: businessSummaryVal,
sector: json['sector']?.toString(),
industry: json['industry']?.toString(),
country: json['country']?.toString(),
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
currentPrice: parseDouble(json['currentPrice']),
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
dayChangePercent: parseDouble(json['dayChangePercent']),
fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']),
enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']),
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']),
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']),
sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(),
industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(),
country: assetMap?['country']?.toString() ?? json['country']?.toString(),
employees: (assetMap?['employees'] ?? json['employees']) is int
? (assetMap?['employees'] ?? json['employees']) as int
: int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''),
currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0,
dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0,
dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0,
fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
enterpriseValue: evVal,
peRatioTrailing: parseNullableDouble(fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing']),
peRatioForward: parseNullableDouble(fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? json['peRatioForward']),
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? json['pbRatio']),
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']),
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']),
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
psRatio: parseNullableDouble(fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
evToEbitda: parseNullableDouble(fundMap?['enterpriseToEbitda'] ?? fundMap?['evToEbitda'] ?? json['evToEbitda']),
evToRevenue: evToRevVal,
totalRevenue: totalRev,
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowth'] ?? fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
grossProfit: grossProf,
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
dilutedEps: parseNullableDouble(fundMap?['dilutedEps'] ?? json['dilutedEps']),
dilutedEps: parseNullableDouble(fundMap?['trailingEps'] ?? fundMap?['dilutedEps'] ?? json['dilutedEps']),
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashflow'] ?? fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
freeCashFlow: parseNullableDouble(fundMap?['freeCashflow'] ?? fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
grossMargin: grossMarginVal,
operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']),
netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']),
operatingMargin: parseNullableDouble(fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
netProfitMargin: parseNullableDouble(fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
@@ -299,424 +254,51 @@ class FundamentalDataModel extends Equatable {
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? json['dividendYield']),
dividendYield: parseNullableDouble(fundMap?['dividendYield'] ?? json['dividendYield']),
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
exDividendDate: exDividendDateVal,
nextEarningsDate: nextEarningsDateVal,
exDividendDate: fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString(),
nextEarningsDate: fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString(),
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
consensusRating: (fundMap?['consensusRating'] ?? json['consensusRating'])?.toString(),
consensusRating: fundMap?['consensusRating']?.toString() ?? json['consensusRating']?.toString(),
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
executives: (json['executives'] as List?)
?.map((e) => CompanyExecutiveModel.fromJson(e is Map<String, dynamic> ? e : {}))
?.whereType<Map<String, dynamic>>()
.map((e) => CompanyExecutiveModel.fromJson(e))
.toList() ??
[],
financialStatements: (json['financialStatements'] as List?)
?.map((e) => FinancialStatementModel.fromJson(e is Map<String, dynamic> ? e : {}))
?.whereType<Map<String, dynamic>>()
.map((e) => FinancialStatementModel.fromJson(e))
.toList() ??
[],
estimates: (json['estimates'] as List?)
?.map((e) => ForwardEstimateModel.fromJson(e is Map<String, dynamic> ? e : {}))
?.whereType<Map<String, dynamic>>()
.map((e) => ForwardEstimateModel.fromJson(e))
.toList() ??
[],
availableTickers: availableTickersList,
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'primaryTicker': primaryTicker,
'ticker': ticker,
'companyName': companyName,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'businessSummary': businessSummary,
'sector': sector,
'industry': industry,
'country': country,
'employees': employees,
'currentPrice': currentPrice,
'dayChangeAbsolute': dayChangeAbsolute,
'dayChangePercent': dayChangePercent,
'fiftyTwoWeekHigh': fiftyTwoWeekHigh,
'fiftyTwoWeekLow': fiftyTwoWeekLow,
'marketCapitalization': marketCapitalization,
'enterpriseValue': enterpriseValue,
'peRatioTrailing': peRatioTrailing,
'peRatioForward': peRatioForward,
'pegRatio': pegRatio,
'pbRatio': pbRatio,
'psRatio': psRatio,
'evToEbitda': evToEbitda,
'evToRevenue': evToRevenue,
'grossMargin': grossMargin,
'operatingMargin': operatingMargin,
'netProfitMargin': netProfitMargin,
'returnOnEquity': returnOnEquity,
'returnOnAssets': returnOnAssets,
'returnOnInvestedCapital': returnOnInvestedCapital,
'debtToEquity': debtToEquity,
'currentRatio': currentRatio,
'quickRatio': quickRatio,
'dividendYield': dividendYield,
'payoutRatio': payoutRatio,
'exDividendDate': exDividendDate,
'nextEarningsDate': nextEarningsDate,
'percentHeldByInstitutions': percentHeldByInstitutions,
'percentHeldByInsiders': percentHeldByInsiders,
'shortRatio': shortRatio,
'shortPercentOfFloat': shortPercentOfFloat,
'consensusRating': consensusRating,
'priceTargetLow': priceTargetLow,
'priceTargetHigh': priceTargetHigh,
'priceTargetMedian': priceTargetMedian,
'priceTargetMean': priceTargetMean,
'executives': executives.map((e) => e.toJson()).toList(),
'financialStatements': financialStatements.map((e) => e.toJson()).toList(),
'estimates': estimates.map((e) => e.toJson()).toList(),
'availableTickers': availableTickers.map((e) => e.toJson()).toList(),
};
}
@override
List<Object?> get props => [
isin,
primaryTicker,
ticker,
companyName,
exchange,
tradingCurrency,
businessSummary,
sector,
industry,
country,
employees,
currentPrice,
dayChangeAbsolute,
dayChangePercent,
fiftyTwoWeekHigh,
fiftyTwoWeekLow,
marketCapitalization,
enterpriseValue,
peRatioTrailing,
peRatioForward,
pegRatio,
pbRatio,
psRatio,
evToEbitda,
evToRevenue,
grossMargin,
operatingMargin,
netProfitMargin,
returnOnEquity,
returnOnAssets,
returnOnInvestedCapital,
debtToEquity,
currentRatio,
quickRatio,
dividendYield,
payoutRatio,
exDividendDate,
nextEarningsDate,
percentHeldByInstitutions,
percentHeldByInsiders,
shortRatio,
shortPercentOfFloat,
consensusRating,
priceTargetLow,
priceTargetHigh,
priceTargetMedian,
priceTargetMean,
executives,
financialStatements,
estimates,
availableTickers,
isin, primaryTicker, ticker, companyName, exchange, tradingCurrency,
businessSummary, sector, industry, country, employees, currentPrice,
dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow,
marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward,
pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin,
operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets,
returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio,
dividendYield, payoutRatio, exDividendDate, nextEarningsDate,
percentHeldByInstitutions, percentHeldByInsiders, shortRatio,
shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh,
priceTargetMedian, priceTargetMean, executives, financialStatements,
estimates, availableTickers,
];
}
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
double? compVal;
if (json['compensation'] != null) {
compVal = double.tryParse(json['compensation'].toString());
} else if (json['payment'] != null) {
final pStr = json['payment'].toString().trim().toUpperCase().replaceAll('\$', '').replaceAll('', '').replaceAll('£', '').replaceAll(',', '').replaceAll(' ', '');
if (pStr.endsWith('M')) {
final numPart = double.tryParse(pStr.substring(0, pStr.length - 1));
if (numPart != null) compVal = numPart * 1e6;
} else if (pStr.endsWith('K')) {
final numPart = double.tryParse(pStr.substring(0, pStr.length - 1));
if (numPart != null) compVal = numPart * 1e3;
} else if (pStr.endsWith('B')) {
final numPart = double.tryParse(pStr.substring(0, pStr.length - 1));
if (numPart != null) compVal = numPart * 1e9;
} else {
compVal = double.tryParse(pStr);
}
}
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: compVal,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
'age': age,
'compensation': compensation,
};
}
@override
List<Object?> get props => [name, title, age, compensation];
}
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null,
expectedEps: json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null,
expectedGrowthRate: json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
'expectedRevenue': expectedRevenue,
'expectedEps': expectedEps,
'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
class TickerModel extends Equatable {
final String ticker;
final String? exchange;
final String? tradingCurrency;
final double currentPrice;
const TickerModel({
required this.ticker,
this.exchange,
this.tradingCurrency,
this.currentPrice = 0.0,
});
factory TickerModel.fromJson(Map<String, dynamic> json) {
return TickerModel(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
currentPrice: json['currentPrice'] != null ? double.tryParse(json['currentPrice'].toString()) ?? 0.0 : 0.0,
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -198,15 +198,16 @@ class ChartPatternModel extends Equatable {
class TechnicalAnalysisModel extends Equatable {
final String symbol;
final String currency;
final double? currentPrice;
final String trend;
final String rsi;
final String macd;
final String overallSignal;
final String sma50;
final String sma200;
final double vix;
final String sp500Trend;
final double dxy;
final double? vix;
final String? sp500Trend;
final double? dxy;
final double? stopLossAtr;
final List<CandleModel> candles;
final List<IndicatorModel> indicators;
@@ -216,15 +217,16 @@ class TechnicalAnalysisModel extends Equatable {
const TechnicalAnalysisModel({
required this.symbol,
this.currency = 'EUR',
this.currentPrice,
required this.trend,
required this.rsi,
required this.macd,
required this.overallSignal,
required this.sma50,
required this.sma200,
this.vix = 16.5,
this.sp500Trend = 'Bullish',
this.dxy = 104.2,
this.vix,
this.sp500Trend,
this.dxy,
this.stopLossAtr,
this.candles = const [],
this.indicators = const [],
@@ -245,7 +247,6 @@ class TechnicalAnalysisModel extends Equatable {
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
final regime = json['marketRegime'] as Map<String, dynamic>?;
@@ -259,17 +260,18 @@ class TechnicalAnalysisModel extends Equatable {
}
return TechnicalAnalysisModel(
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
currency: json['currency']?.toString() ?? 'EUR',
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
trend: parsedTrend,
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
overallSignal: parsedSignal,
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
vix: (regime?['vixValue'] as num?)?.toDouble() ?? 16.5,
sp500Trend: regime?['marketTrend']?.toString() ?? 'Bullish',
dxy: (regime?['dxyValue'] as num?)?.toDouble() ?? 104.2,
vix: (regime?['vixValue'] as num?)?.toDouble(),
sp500Trend: regime?['marketTrend']?.toString(),
dxy: (regime?['dxyValue'] as num?)?.toDouble(),
stopLossAtr: lastInd?.recommendedStopLoss,
candles: candlesList,
indicators: indicatorsList,
@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
class TickerModel extends Equatable {
final String ticker;
final String? exchange;
final String? tradingCurrency;
final double? currentPrice;
const TickerModel({
required this.ticker,
this.exchange,
this.tradingCurrency,
this.currentPrice,
});
factory TickerModel.fromJson(Map<String, dynamic> json) {
return TickerModel(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
if (exchange != null) 'exchange': exchange,
if (tradingCurrency != null) 'tradingCurrency': tradingCurrency,
if (currentPrice != null) 'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -1,103 +1,124 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_dto.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
import 'package:finlytic_app/features/trades/models/close_trade_request_dto.dart';
import 'package:finlytic_app/features/trades/repositories/trade_repository.dart';
class AssetRepository {
final ApiClient apiClient;
final TradeRepository _tradeRepository;
AssetRepository({required this.apiClient});
// In-memory request deduplication & cache
final Map<String, Future<FundamentalDataModel?>> _pendingFundamentals = {};
final Map<String, FundamentalDataModel> _fundamentalsCache = {};
final Map<String, Future<TechnicalAnalysisModel?>> _pendingTechnicals = {};
final Map<String, TechnicalAnalysisModel> _technicalsCache = {};
AssetRepository({required this.apiClient, TradeRepository? tradeRepository})
: _tradeRepository = tradeRepository ?? TradeRepository(apiClient: apiClient);
String _buildCacheKey(String isin, String? ticker) => '${isin.toUpperCase()}_${(ticker ?? '').toUpperCase()}';
Future<FundamentalDataModel?> getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
final key = _buildCacheKey(isin, ticker);
if (!forceRefresh && _fundamentalsCache.containsKey(key)) {
return _fundamentalsCache[key];
}
if (_pendingFundamentals.containsKey(key)) {
return await _pendingFundamentals[key];
}
final future = _fetchFundamentals(isin, forceRefresh, ticker: ticker);
_pendingFundamentals[key] = future;
try {
final result = await future;
if (result != null) {
_fundamentalsCache[key] = result;
}
return result;
} finally {
_pendingFundamentals.remove(key);
}
}
Future<FundamentalDataModel?> _fetchFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching fundamentals for $isin: $e');
}
} catch (_) {}
return null;
}
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
final key = _buildCacheKey(isin, ticker);
if (!forceRefresh && _technicalsCache.containsKey(key)) {
return _technicalsCache[key];
}
if (_pendingTechnicals.containsKey(key)) {
return await _pendingTechnicals[key];
}
final future = _fetchTechnicals(isin, forceRefresh, ticker: ticker);
_pendingTechnicals[key] = future;
try {
final result = await future;
if (result != null) {
_technicalsCache[key] = result;
}
return result;
} finally {
_pendingTechnicals.remove(key);
}
}
Future<TechnicalAnalysisModel?> _fetchTechnicals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching TA for $isin: $e');
}
} catch (_) {}
return null;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
try {
String url = '/api/v1/user/trades?isin=$isin';
if (status != null) url += '&status=$status';
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> list = res.data;
return list.map((json) => TradeModel.fromJson(json)).toList();
}
} catch (e) {
print('Error fetching trades for $isin: $e');
}
return [];
return _tradeRepository.fetchTrades(isin: isin, status: status);
}
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
try {
final body = payload != null ? payload.toJson() : {'isin': isin};
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return ManualAnalysisResponseDto.fromJson(res.data);
}
return null;
} catch (e) {
print('Error triggering manual analysis for $isin: $e');
rethrow;
final body = payload != null ? payload.toJson() : {'isin': isin};
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
return ManualAnalysisResponseDto.fromJson(res.data);
}
return null;
}
Future<void> rejectTrade(String tradeId) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
} catch (e) {
print('Error rejecting trade $tradeId: $e');
throw e;
}
}
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
try {
final payload = tradeAcceptanceDto.toJson();
await apiClient.post('/api/v1/user/trades/accept', data: payload);
} catch (e) {
print('Error accepting trade: $e');
throw e;
}
}
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
Future<void> closeTrade(String tradeId, double exitPrice) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
} catch (e) {
print('Error closing trade $tradeId: $e');
throw e;
}
}
Future<void> closeTrade(String tradeId, double exitPrice) async =>
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
}
@@ -125,6 +125,10 @@ class MetricExplanations {
},
};
static bool hasExplanation(String key) => data.containsKey(key);
static void showModal(BuildContext context, String key) => show(context, key);
static void show(BuildContext context, String key) {
final info = data[key];
if (info == null) return;
@@ -117,6 +117,17 @@ class PatternExplanations {
return colors[patternType.hashCode.abs() % colors.length];
}
static String getGermanName(String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
orElse: () => '',
);
if (key.isNotEmpty && dictionary.containsKey(key)) {
return dictionary[key]!['title'] ?? rawPatternType;
}
return rawPatternType;
}
static void showPatternDetails(BuildContext context, String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../bloc/header/asset_header_bloc.dart';
import '../bloc/header/asset_header_event.dart';
import '../bloc/fundamentals/asset_fundamentals_event.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/trades/asset_trades_bloc.dart';
import '../bloc/trades/asset_trades_event.dart';
import '../repositories/asset_repository.dart';
@@ -32,14 +32,12 @@ class AssetDetailScreen extends StatelessWidget {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AssetHeaderBloc(repository: repository)
..add(LoadAssetHeader(isin, ticker: symbol)),
create: (context) => AssetFundamentalsBloc(repository: repository)
..add(LoadAssetFundamentals(isin, ticker: symbol)),
),
BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository),
create: (context) => AssetTechnicalBloc(repository: repository)
..add(LoadAssetTechnical(isin, ticker: symbol)),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)
@@ -67,3 +65,4 @@ class AssetDetailScreen extends StatelessWidget {
);
}
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/technical/asset_technical_state.dart';
import '../widgets/chart/candlestick_chart.dart';
import '../widgets/technical/indicator_ribbon_bar.dart';
class FullscreenChartScreen extends StatefulWidget {
final String isin;
final String? symbol;
final AssetTechnicalBloc technicalBloc;
const FullscreenChartScreen({
super.key,
required this.isin,
this.symbol,
required this.technicalBloc,
});
static Future<void> open(BuildContext context, {required String isin, String? symbol}) {
final bloc = context.read<AssetTechnicalBloc>();
return Navigator.of(context).push(
PageRouteBuilder(
opaque: true,
pageBuilder: (ctx, anim, secAnim) => FullscreenChartScreen(
isin: isin,
symbol: symbol,
technicalBloc: bloc,
),
transitionsBuilder: (ctx, anim, secAnim, child) {
return FadeTransition(opacity: anim, child: child);
},
),
);
}
@override
State<FullscreenChartScreen> createState() => _FullscreenChartScreenState();
}
class _FullscreenChartScreenState extends State<FullscreenChartScreen> {
@override
void initState() {
super.initState();
// Rotate to landscape on mobile devices
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
// Restore orientation back to default portrait/auto
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocProvider.value(
value: widget.technicalBloc,
child: Scaffold(
backgroundColor: theme.darkBackground,
body: SafeArea(
child: BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, state) {
if (state is AssetTechnicalLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is AssetTechnicalLoaded && state.data != null) {
final data = state.data!;
final activePatterns = <ChartPatternModel>[];
for (int i = 0; i < data.patterns.length; i++) {
if (!state.disabledPatternIndices.contains(i)) {
activePatterns.add(data.patterns[i]);
}
}
return Column(
children: [
_buildHeader(context, theme, state),
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
return CandlestickChart(
candles: data.candles,
indicators: data.indicators,
patterns: activePatterns,
signals: data.signals,
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
showSupertrend: state.showSupertrend,
height: constraints.maxHeight,
isFullscreen: true,
onToggleFullscreen: () => Navigator.of(context).pop(),
);
},
),
),
],
);
}
return Center(
child: Text('Keine Chartdaten verfügbar', style: TextStyle(color: theme.textMuted)),
);
},
),
),
),
);
}
Widget _buildHeader(BuildContext context, ThemePreset theme, AssetTechnicalLoaded state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
border: Border(bottom: BorderSide(color: theme.glassBorder)),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 20, color: Colors.white70),
tooltip: 'Zurück',
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 4),
Text(
widget.symbol ?? widget.isin,
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14),
),
const SizedBox(width: 12),
Expanded(
child: IndicatorRibbonBar(
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showSupertrend: state.showSupertrend,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.fullscreen_exit, size: 22, color: Colors.white70),
tooltip: 'Vollbild beenden',
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
}
@@ -4,9 +4,6 @@ import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
@@ -21,8 +18,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
final String? name;
final String? selectedTicker;
const AssetPageDesktopLayout(
{super.key, required this.isin, this.selectedTicker, this.name});
const AssetPageDesktopLayout({
super.key,
required this.isin,
this.selectedTicker,
this.name,
});
@override
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
@@ -31,12 +32,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedExchange;
String? _selectedTicker;
@override
void initState() {
super.initState();
_selectedTicker = widget.selectedTicker;
_tabController = TabController(length: 3, vsync: this);
}
@@ -48,11 +49,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
@@ -65,10 +63,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
forceRefresh: true,
exchange: _selectedExchange,
ticker: _selectedTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
@@ -78,119 +74,100 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = widget.selectedTicker;
});
}
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
}
},
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Hero Header
AssetHeroHeader(
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Hero Header
AssetHeroHeader(
isin: widget.isin,
name: widget.name ?? widget.isin,
symbol: _selectedTicker ?? widget.selectedTicker,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
// 2. Full-Width Interactive Chart Section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
name: widget.name ?? widget.isin,
symbol: _selectedTicker ?? widget.selectedTicker,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 460,
),
),
// 2. Full-Width Interactive Chart Section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 460,
),
const SizedBox(height: 8),
// 3. Detailed Sections & Fundamentals under the Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
const SizedBox(height: 8),
// 3. Detailed Sections & Fundamentals under the Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'FUNDAMENTALS & ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER & SIGNALE'),
Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 600,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 18),
text: 'FUNDAMENTALS & ÜBERSICHT'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 18),
text: 'MUSTER & SIGNALE'),
Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 600,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
const SizedBox(height: 24),
],
),
),
const SizedBox(height: 24),
],
),
);
}
@@ -4,9 +4,6 @@ import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
@@ -21,8 +18,12 @@ class AssetPageMobileLayout extends StatefulWidget {
final String? name;
final String? selectedTicker;
const AssetPageMobileLayout(
{super.key, required this.isin, this.selectedTicker, this.name});
const AssetPageMobileLayout({
super.key,
required this.isin,
this.selectedTicker,
this.name,
});
@override
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
@@ -36,7 +37,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
@override
void initState() {
super.initState();
//_selectedTicker = widget.selectedTicker;
_selectedTicker = widget.selectedTicker;
_tabController = TabController(length: 3, vsync: this);
}
@@ -48,11 +49,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
//_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
@@ -65,10 +63,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
forceRefresh: true, ticker: _selectedTicker));
// AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true
// for fundamentals, and the listener below will fetch the updated data with forceRefresh=false.
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
@@ -78,113 +74,102 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = widget.selectedTicker;
});
}
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
widget.isin,
ticker: _selectedTicker,
forceRefresh: false));
}
},
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Hero Header
AssetHeroHeader(
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Hero Header
AssetHeroHeader(
isin: widget.isin,
name: widget.name ?? widget.isin,
symbol: _selectedTicker ?? widget.selectedTicker,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
// 2. Interactive Chart
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
name: widget.name ?? widget.isin,
symbol: _selectedTicker ?? widget.selectedTicker,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 320,
),
),
// 2. Full-Width Interactive Chart Section
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showChartOnly: true,
chartHeight: 330,
),
const SizedBox(height: 8),
// 3. Tabbed Detailed Analysis
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
const SizedBox(height: 6),
// 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades)
Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 12),
tabs: const [
Tab(text: 'FUNDAMENTALS'),
Tab(text: 'MUSTER & SIGNALE'),
Tab(text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 500,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 12),
tabs: const [
Tab(
icon: Icon(Icons.analytics_outlined, size: 16),
text: 'FUNDAMENTALS'),
Tab(
icon: Icon(Icons.architecture_outlined, size: 16),
text: 'MUSTER & SIGNALE'),
Tab(
icon: Icon(Icons.candlestick_chart_outlined, size: 16),
text: 'TRADES'),
],
),
AnimatedBuilder(
animation: _tabController,
builder: (context, _) {
switch (_tabController.index) {
case 0:
return FundamentalsTab(
isin: widget.isin,
symbol: _selectedTicker,
isEmbedded: true,
);
case 1:
return TechnicalTab(
isin: widget.isin,
symbol: _selectedTicker,
showDetailsOnly: true,
);
case 2:
return SizedBox(
height: 500,
child: TradesTab(symbol: widget.isin),
);
default:
return const SizedBox.shrink();
}
},
),
],
),
const SizedBox(height: 24),
],
),
),
const SizedBox(height: 24),
],
),
);
}
@@ -3,14 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../models/fundamental_data_model.dart';
import '../../utils/metric_explanations.dart';
import '../../widgets/fundamentals/analyst_price_target_card.dart';
import '../../widgets/fundamentals/fundamental_category_panels.dart';
import '../../widgets/fundamentals/company_profile_section.dart';
class FundamentalsTab extends StatefulWidget {
class FundamentalsTab extends StatelessWidget {
final String isin;
final String? symbol;
final bool isEmbedded;
@@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget {
required this.isin,
});
@override
State<FundamentalsTab> createState() => _FundamentalsTabState();
}
class _FundamentalsTabState extends State<FundamentalsTab> {
String _sym = '\$';
String _curCode = 'USD';
@override
void initState() {
super.initState();
String _getCurrencySymbol(String? ticker) {
if (ticker == null || ticker.isEmpty) return '';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
return '';
}
if (t.endsWith('.L')) return '£';
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
return '\$';
}
@override
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
super.didUpdateWidget(oldWidget);
String _getCurrencyCode(String? ticker) {
if (ticker == null || ticker.isEmpty) return 'EUR';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
return 'EUR';
}
if (t.endsWith('.L')) return 'GBP';
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CAD';
return 'USD';
}
@override
@@ -60,7 +64,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
@@ -70,89 +74,68 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
);
}
if (state is AssetFundamentalsLoaded) {
final data = state.data;
if (data != null) {
_sym = _getCurrencySymbol(data.ticker);
_curCode = _getCurrencyCode(data.ticker);
}
if (data == null) {
return _buildEmptyState();
}
if (state is AssetFundamentalsLoaded && state.data != null) {
final data = state.data!;
final sym = _getCurrencySymbol(data.ticker);
final curCode = _getCurrencyCode(data.ticker);
return SingleChildScrollView(
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Analyst Forecasts & Price Targets Header Card
_buildPriceTargetCard(data),
AnalystPriceTargetCard(data: data, currencySymbol: sym),
const SizedBox(height: 20),
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
_buildCategoryPanels(data),
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
const SizedBox(height: 20),
// 3. Company Description & Detailed Executive Board
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
const SizedBox(height: 12),
_buildProfileSection(data),
CompanyProfileSection(data: data, currencySymbol: sym),
],
),
);
}
return _buildEmptyState();
return _buildEmptyState(context);
},
);
}
Widget _buildPriceTargetCard(FundamentalDataModel data) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
Widget _buildEmptyState(BuildContext context) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.analytics_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Aktualisieren'),
),
],
),
),
);
}
@@ -188,16 +171,13 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
}
return SingleChildScrollView(
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Price Target Card Shimmer
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
const SizedBox(height: 20),
// 3 Category Panels Shimmer
if (isDesktop)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -234,9 +214,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
panelShimmer(),
],
),
const SizedBox(height: 20),
// Profile Section Shimmer
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
const SizedBox(height: 12),
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
@@ -244,454 +222,4 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
),
);
}
Widget _buildProfileSection(FundamentalDataModel data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Mitarbeiter: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text('Führungskräfte (Board)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14)),
const SizedBox(height: 8),
...data.executives.take(5).map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.person_outline, color: AppTheme.primaryEmerald, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
Text(e.title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
if (e.compensation != null && e.compensation! > 0)
Text(
_formatNumber(e.compensation),
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12),
),
],
),
),
)),
],
],
);
}
Widget _buildProfileBadge(String label, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.insert_chart_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text('Für dieses Asset wurden noch keine Bilanz- oder Bewertungskennzahlen erfasst.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.download),
label: const Text('Daten von Backend abrufen'),
),
],
),
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildCategoryPanels(FundamentalDataModel data) {
final valuationItems = [
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)),
_MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)),
_MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)),
_MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
];
final profitabilityItems = [
_MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)),
_MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)),
_MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)),
_MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_MetricRowItem('EBITDA', _formatNumber(data.ebitda)),
_MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)),
_MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)),
_MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)),
_MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)),
_MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)),
_MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)),
_MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)),
];
final dividendItems = [
_MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)),
_MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'),
_MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
];
final panel1 = _buildCategoryPanel(
title: 'Bewertungskennzahlen & Multiples',
icon: Icons.analytics_outlined,
items: valuationItems,
);
final panel2 = _buildCategoryPanel(
title: 'Rentabilität & Finanzen',
icon: Icons.account_balance_outlined,
items: profitabilityItems,
);
final panel3 = _buildCategoryPanel(
title: 'Dividenden & Termine',
icon: Icons.pie_chart_outline,
items: dividendItems,
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1050) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 14),
Expanded(child: panel2),
const SizedBox(width: 14),
Expanded(child: panel3),
],
);
} else if (constraints.maxWidth >= 680) {
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 12),
Expanded(child: panel2),
],
),
const SizedBox(height: 12),
panel3,
],
);
} else {
return Column(
children: [
panel1,
const SizedBox(height: 12),
panel2,
const SizedBox(height: 12),
panel3,
],
);
}
},
);
}
Widget _buildCategoryPanel({
required String title,
required IconData icon,
required List<_MetricRowItem> items,
}) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: AppTheme.primaryEmerald, size: 16),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 4),
...items.asMap().entries.map((entry) {
final idx = entry.key;
final item = entry.value;
final isEven = idx % 2 == 0;
return _buildMetricListRow(item.label, item.value, isEven: isEven, valueColor: item.valueColor);
}),
],
),
);
}
Widget _buildMetricListRow(String label, String value, {bool isEven = false, Color? valueColor}) {
return InkWell(
onTap: () => MetricExplanations.show(context, label),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(width: 4),
Icon(Icons.info_outline, size: 11, color: AppTheme.textMuted.withValues(alpha: 0.6)),
],
),
const SizedBox(width: 8),
Flexible(
child: Text(
value,
style: TextStyle(
color: valueColor ?? (value == 'N/A' ? AppTheme.textMuted : Colors.white),
fontWeight: FontWeight.w600,
fontSize: 12,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
String _fmtMultiple(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(2)}x' : 'N/A';
}
String _fmtDays(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(1)} Tage' : 'N/A';
}
String _fmtDebtToEquity(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null) return 'N/A';
// Yahoo liefert D/E als Prozentwert (z. B. 145.23 = 145.23% oder Faktor 1.45x)
if (n > 5) {
return '${(n / 100).toStringAsFixed(2)}x (${n.toStringAsFixed(1)} %)';
}
return '${n.toStringAsFixed(2)}x (${(n * 100).toStringAsFixed(1)} %)';
}
String _fmtPercent(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null) return 'N/A';
// Yahoo liefert Margen/Renditen als Dezimalzahl (z. B. 0.25 = 25%, 1.2 = 120%)
// Wenn |n| <= 2.5 ist, handelt es sich um eine Dezimalquote -> mit 100 multiplizieren
final p = n.abs() <= 2.5 ? n * 100 : n;
return '${p.toStringAsFixed(2)} %';
}
String _fmtCurrency(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null || n == 0) return 'N/A';
return '$_sym${n.toStringAsFixed(2)}';
}
String _fmtDate(dynamic val) {
if (val == null) return 'N/A';
final dt = DateTime.tryParse(val.toString());
return dt != null ? '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}' : val.toString();
}
String _formatNumber(dynamic val) {
if (val == null) return 'N/A';
final num? n = val is num ? val : num.tryParse(val.toString());
if (n == null) return val.toString();
final isNegative = n < 0;
final absVal = n.abs();
final prefix = isNegative ? '-$_sym' : _sym;
if (absVal >= 1e12) {
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bio.';
} else if (absVal >= 1e9) {
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
} else if (absVal >= 1e6) {
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
} else if (absVal >= 1e3) {
return '$prefix${(absVal / 1e3).toStringAsFixed(1)} Tsd.';
} else {
return '$prefix${absVal.toStringAsFixed(2)}';
}
}
/// Leitet das Währungssymbol vom Ticker-Suffix ab.
String _getCurrencySymbol(String? ticker) {
if (ticker == null || ticker.isEmpty) return '\$';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') ||
t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') ||
t.endsWith('.BE') || t.endsWith('.SG') ||
t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') ||
t.endsWith('.MC')) return '';
if (t.endsWith('.L')) return '£';
if (t.endsWith('.SW')) return 'CHF ';
if (t.endsWith('.TO')) return 'CA\$';
if (t.endsWith('.AX')) return 'A\$';
if (t.endsWith('.T')) return '¥';
if (t.endsWith('.HK')) return 'HK\$';
return '\$';
}
/// Leitet den Währungscode vom Ticker-Suffix ab.
String _getCurrencyCode(String? ticker) {
if (ticker == null || ticker.isEmpty) return 'USD';
final t = ticker.toUpperCase();
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') ||
t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') ||
t.endsWith('.BE') || t.endsWith('.SG') ||
t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') ||
t.endsWith('.MC')) return 'EUR';
if (t.endsWith('.L')) return 'GBP';
if (t.endsWith('.SW')) return 'CHF';
if (t.endsWith('.TO')) return 'CAD';
if (t.endsWith('.AX')) return 'AUD';
if (t.endsWith('.T')) return 'JPY';
if (t.endsWith('.HK')) return 'HKD';
return 'USD';
}
}
class _MetricRowItem {
final String label;
final String value;
final Color? valueColor;
const _MetricRowItem(this.label, this.value, {this.valueColor});
}
@@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../utils/metric_explanations.dart';
import '../../utils/pattern_explanations.dart';
import '../../widgets/chart/candlestick_chart.dart';
import '../../widgets/technical/pattern_card_item.dart';
import '../../widgets/technical/signal_card_item.dart';
import '../../widgets/technical/indicator_ribbon_bar.dart';
class TechnicalTab extends StatefulWidget {
import '../fullscreen_chart_screen.dart';
class TechnicalTab extends StatelessWidget {
final String isin;
final String? symbol;
final bool isDesktopLeftPanel;
@@ -30,31 +31,6 @@ class TechnicalTab extends StatefulWidget {
required this.isin,
});
@override
State<TechnicalTab> createState() => _TechnicalTabState();
}
class _TechnicalTabState extends State<TechnicalTab> {
bool _showSma50 = true;
bool _showSma200 = true;
bool _showEma = true;
bool _showPatterns = true;
bool _showSignals = true;
bool _showSupertrend = true;
// Set of disabled pattern indices for individual toggling
final Set<int> _disabledPatternIndices = {};
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(covariant TechnicalTab oldWidget) {
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
@@ -73,12 +49,14 @@ class _TechnicalTabState extends State<TechnicalTab> {
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text(
'Fehler beim Laden der Technischen Analyse: ${state.message}',
style: const TextStyle(color: Colors.white70)),
'Fehler beim Laden der Technischen Analyse: ${state.message}',
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetTechnicalBloc>().add(
LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)),
LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true),
),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
@@ -88,221 +66,91 @@ class _TechnicalTabState extends State<TechnicalTab> {
);
}
if (state is AssetTechnicalLoaded) {
final data = state.data;
List<CandleModel> candles = [];
List<ChartPatternModel> patterns = [];
List<StrategySignalModel> signals = [];
List<IndicatorModel> indicators = [];
if (state is AssetTechnicalLoaded && state.data != null) {
final data = state.data!;
final candles = data.candles;
final patterns = data.patterns;
final signals = data.signals;
final indicators = data.indicators;
if (data != null) {
candles = data.candles
.map((c) => CandleModel(
time: c.timestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume))
.toList();
patterns = data.patterns
.map((p) => ChartPatternModel(
type: p.type,
upperLine: p.upperLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
lowerLine: p.lowerLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
))
.toList();
signals = data.signals
.map((s) => StrategySignalModel(
type: 'strategy',
timestamp: s.date,
direction: s.type,
price: s.price,
description: s.title))
.toList();
indicators = data.indicators
.map((i) => IndicatorModel(
timestamp: i.timestamp,
ema20: i.ema20,
sma50: i.sma50,
sma200: i.sma200,
supertrendUpper: i.supertrendUpper,
supertrendLower: i.supertrendLower,
supertrendDirection: i.supertrendDirection))
.toList();
final activePatterns = <ChartPatternModel>[];
for (int i = 0; i < patterns.length; i++) {
if (!state.disabledPatternIndices.contains(i)) {
activePatterns.add(patterns[i]);
}
}
// Filter patterns according to individual checkbox states
final activePatterns = [
for (int i = 0; i < patterns.length; i++)
if (!_disabledPatternIndices.contains(i)) patterns[i]
];
final chartRibbon = GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildIndicatorChip(
'EMA (20)',
_showEma,
(v) => setState(() => _showEma = v),
Colors.blueAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'SMA (50)',
_showSma50,
(v) => setState(() => _showSma50 = v),
Colors.orangeAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'SMA (200)',
_showSma200,
(v) => setState(() => _showSma200 = v),
Colors.redAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'Supertrend',
_showSupertrend,
(v) => setState(() => _showSupertrend = v),
AppTheme.primaryEmerald),
const SizedBox(width: 6),
_buildIndicatorChip(
'Alle Muster',
_showPatterns,
(v) => setState(() => _showPatterns = v),
Colors.amberAccent),
const SizedBox(width: 6),
_buildIndicatorChip(
'Signale',
_showSignals,
(v) => setState(() => _showSignals = v),
AppTheme.accentCyan),
],
),
),
final chartWidget = CandlestickChart(
candles: candles,
indicators: indicators,
patterns: activePatterns,
signals: signals,
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
showSupertrend: state.showSupertrend,
height: chartHeight,
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
);
final chartWidget = SizedBox(
height: widget.chartHeight,
width: double.infinity,
child: CandlestickChart(
candles: candles,
patterns: activePatterns,
signals: signals,
indicators: indicators,
showPatterns: _showPatterns,
showEma: _showEma,
showSma50: _showSma50,
showSma200: _showSma200,
showSignals: _showSignals,
showSupertrend: _showSupertrend,
),
final chartRibbon = IndicatorRibbonBar(
showSma50: state.showSma50,
showSma200: state.showSma200,
showEma: state.showEma,
showSupertrend: state.showSupertrend,
showPatterns: state.showPatterns,
showSignals: state.showSignals,
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
);
if (widget.showChartOnly) {
if (showChartOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
chartRibbon,
const SizedBox(height: 8),
const SizedBox(height: 10),
chartWidget,
],
);
}
final detailsSection = Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.architecture_outlined,
color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
const Text('Erkannte Chart-Muster & Signale',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white)),
],
),
if (patterns.isNotEmpty)
TextButton.icon(
onPressed: () {
setState(() {
if (_disabledPatternIndices.length ==
patterns.length) {
_disabledPatternIndices.clear();
} else {
_disabledPatternIndices.addAll(
List.generate(
patterns.length, (i) => i));
}
});
},
icon: Icon(
_disabledPatternIndices.isEmpty
? Icons.deselect
: Icons.select_all,
size: 16,
color: Colors.amberAccent),
label: Text(
_disabledPatternIndices.isEmpty
? 'Alle abwählen'
: 'Alle anwählen',
style: const TextStyle(
color: Colors.amberAccent, fontSize: 12)),
),
],
),
const SizedBox(height: 12),
if (patterns.isEmpty && signals.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text(
'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.',
style: TextStyle(
color: AppTheme.textMuted, fontSize: 12)),
),
)
else ...[
if (patterns.isNotEmpty) ...[
Text(
'Formationen & Trendlinien (Mit Checkbox im Chart schalten):',
style: TextStyle(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
fontSize: 13)),
const SizedBox(height: 6),
...List.generate(
patterns.length,
(index) =>
_buildPatternCard(patterns[index], index)),
const SizedBox(height: 12),
],
if (signals.isNotEmpty) ...[
Text('Strategie-Signale:',
style: TextStyle(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w600,
fontSize: 13)),
const SizedBox(height: 6),
...signals.map((s) => _buildSignalCard(s)),
],
],
final detailsSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (patterns.isNotEmpty) ...[
const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
for (int i = 0; i < patterns.length; i++)
PatternCardItem(
pattern: patterns[i],
index: i,
isEnabled: !state.disabledPatternIndices.contains(i),
onToggle: (enabled) {
context.read<AssetTechnicalBloc>().add(
TogglePatternFilter(patternIndex: i, enabled: enabled),
);
},
),
const SizedBox(height: 16),
],
),
if (signals.isNotEmpty) ...[
const Text('Strategische Kauf- & Verkaufssignale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
for (final sig in signals) SignalCardItem(signal: sig),
],
],
);
if (widget.showDetailsOnly) {
if (showDetailsOnly) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: detailsSection,
@@ -325,228 +173,26 @@ class _TechnicalTabState extends State<TechnicalTab> {
}
return Center(
child: Text('Keine technisches Indikatoren verfügbar',
style: TextStyle(color: AppTheme.textMuted)),
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
);
},
);
}
Widget _buildPatternCard(ChartPatternModel pattern, int index) {
final isEnabled = !_disabledPatternIndices.contains(index);
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
DateTime? startDate;
DateTime? endDate;
if (allPoints.isNotEmpty) {
allPoints.sort((a, b) => a.time.compareTo(b.time));
startDate = allPoints.first.time;
endDate = allPoints.last.time;
}
final dateFormat = DateFormat('dd.MM.yy');
final dateStr = startDate != null && endDate != null
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
: 'Unbekannt';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// Checkbox for individual pattern toggling on the chart
Checkbox(
value: isEnabled,
activeColor: patternColor,
checkColor: Colors.black,
side:
BorderSide(color: patternColor.withValues(alpha: 0.6)),
onChanged: (bool? val) {
setState(() {
if (val == true) {
_disabledPatternIndices.remove(index);
} else {
_disabledPatternIndices.add(index);
}
});
},
),
Expanded(
child: InkWell(
onTap: () => PatternExplanations.showPatternDetails(
context, pattern.type),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled
? patternColor.withValues(alpha: 0.15)
: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.polyline_outlined,
color: isEnabled
? patternColor
: AppTheme.textMuted,
size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pattern.type,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isEnabled
? Colors.white
: AppTheme.textMuted,
fontSize: 14,
decoration: isEnabled
? null
: TextDecoration.lineThrough,
),
),
const SizedBox(width: 6),
Icon(Icons.info_outline,
size: 14, color: AppTheme.textMuted),
],
),
Text(
'Zeitraum: $dateStr\n'
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
style: TextStyle(
color: AppTheme.textMuted, fontSize: 11, height: 1.3),
),
],
),
),
StatusBadge(
label: isEnabled ? 'AKTIV' : 'AUS',
color:
isEnabled ? patternColor : AppTheme.textMuted,
),
],
),
),
),
),
],
),
),
);
}
Widget _buildSignalCard(StrategySignalModel signal) {
final isBuy = signal.type.toUpperCase() == 'BUY';
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(isBuy ? Icons.north_east : Icons.south_east,
color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(signal.type.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
fontSize: 14)),
const SizedBox(width: 8),
Text('@ €${signal.price.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13)),
],
),
const SizedBox(height: 4),
Text(
signal.description.isNotEmpty
? signal.description
: 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
style: TextStyle(
color: AppTheme.textSecondary, fontSize: 12)),
],
),
),
StatusBadge(label: 'SIGNAL', color: color),
],
),
),
);
}
Widget _buildIndicatorChip(String label, bool isSelected,
ValueChanged<bool> onChanged, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
selected: isSelected,
label: Text(label,
style: TextStyle(
color: isSelected ? Colors.black : color,
fontSize: 11,
fontWeight: FontWeight.bold)),
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
side: BorderSide(color: color.withValues(alpha: 0.4)),
showCheckmark: false,
onSelected: onChanged,
),
InkWell(
onTap: () => MetricExplanations.show(context, label),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child:
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
),
),
],
);
}
Widget _buildTechnicalShimmer(BuildContext context) {
if (widget.showChartOnly) {
if (showChartOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
const SizedBox(height: 8),
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
],
);
}
if (widget.showDetailsOnly) {
if (showDetailsOnly) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
@@ -570,7 +216,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
children: [
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
const SizedBox(height: 8),
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
const SizedBox(height: 16),
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
const SizedBox(height: 14),
@@ -4,13 +4,15 @@ import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/shimmer_loading.dart';
import '../../../../core/widgets/status_badge.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/trades/widgets/trade_execution_dialog.dart';
import '../../../trades/models/trade_model.dart';
import '../../../trades/widgets/trade_execution_dialog.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../bloc/trades/asset_trades_state.dart';
import '../../widgets/trades/live_trade_settings_dialog.dart';
import '../../widgets/trades/manual_analysis_dialog.dart';
import '../../widgets/trades/close_trade_dialog.dart';
import '../../widgets/trades/asset_trade_item_card.dart';
class TradesTab extends StatefulWidget {
final String symbol;
@@ -22,13 +24,13 @@ class TradesTab extends StatefulWidget {
class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false;
// Settings State
double _defaultPositionSize = 2500.0;
double _defaultLeverage = 5.0;
double _defaultRiskScore = 50.0;
double _defaultOrderFee = 1.0;
bool _autoAcceptSignals = false;
LiveTradeSettings _settings = const LiveTradeSettings(
defaultPositionSize: 2500.0,
defaultLeverage: 5.0,
defaultRiskScore: 50.0,
defaultOrderFee: 1.0,
autoAcceptSignals: false,
);
@override
void initState() {
@@ -36,413 +38,9 @@ class _TradesTabState extends State<TradesTab> {
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
void _showLiveTradeSettingsDialog(BuildContext context) {
double tempPos = _defaultPositionSize;
double tempLev = _defaultLeverage;
double tempRisk = _defaultRiskScore;
double tempFee = _defaultOrderFee;
bool tempAuto = _autoAcceptSignals;
final posController = TextEditingController(text: tempPos.toStringAsFixed(0));
final levController = TextEditingController(text: tempLev.toStringAsFixed(1));
final feeController = TextEditingController(text: tempFee.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.settings, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('Live Trade Einstellungen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 440,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Standard Trade-Vorgaben für Ihr Depot:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: posController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Investment (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempPos = double.tryParse(v) ?? tempPos,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: levController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempLev = double.tryParse(v) ?? tempLev,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: feeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Ordergebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempFee = double.tryParse(v) ?? tempFee,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Standard Risiko-Toleranz:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text('${tempRisk.toInt()}/100', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
Slider(
value: tempRisk,
min: 0,
max: 100,
divisions: 100,
activeColor: AppTheme.primaryEmerald,
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => tempRisk = val),
),
const SizedBox(height: 10),
SwitchListTile(
value: tempAuto,
activeThumbColor: AppTheme.primaryEmerald,
title: const Text('KI-Signale automatisch annehmen', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
subtitle: Text('Führt eingehende Signale direkt im Depot aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
onChanged: (val) => setModalState(() => tempAuto = val),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
setState(() {
_defaultPositionSize = tempPos;
_defaultLeverage = tempLev;
_defaultRiskScore = tempRisk;
_defaultOrderFee = tempFee;
_autoAcceptSignals = tempAuto;
});
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Live Trade Einstellungen gespeichert.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.save),
label: const Text('Einstellungen Speichern'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
void _showCloseTradeDialog(BuildContext context, TradeModel trade) {
final tradesBloc = context.read<AssetTradesBloc>();
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('Trade Position Schließen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Z.B. 105.50',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final exitPrice = double.tryParse(exitController.text) ?? entry;
final tradeId = trade.id;
if (tradeId.isNotEmpty) {
tradesBloc.add(CloseTradeEvent(tradeId, widget.symbol, exitPrice));
}
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tradeId geschlossen zu €${exitPrice.toStringAsFixed(2)}.'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.check_circle),
label: const Text('Position Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
),
),
],
);
},
);
}
void _showAnalysisParametersDialog(BuildContext context) {
final tradesBloc = context.read<AssetTradesBloc>();
final minTimeframeController = TextEditingController(text: '1');
final maxTimeframeController = TextEditingController(text: '14');
double riskScore = _defaultRiskScore;
String timeframeUnit = 'Tage';
String instrumentType = 'Aktie / ETF (Direktinvestment)';
final notesController = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('KI-Analyse Konfigurieren', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Asset / ISIN: ${widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
// 1. Haltedauer von - bis mit Einheit
const Text('Geplante Haltedauer:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: minTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: maxTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: timeframeUnit,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [
DropdownMenuItem(value: 'Stunden', child: Text('Stunden')),
DropdownMenuItem(value: 'Tage', child: Text('Tage')),
DropdownMenuItem(value: 'Wochen', child: Text('Wochen')),
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
],
onChanged: (val) {
if (val != null) setModalState(() => timeframeUnit = val);
},
),
),
],
),
const SizedBox(height: 16),
// 2. Risikobereitschaft 0-100 Slider
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text(
'${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
style: TextStyle(color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
Slider(
value: riskScore,
min: 0,
max: 100,
divisions: 100,
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => riskScore = val),
),
const SizedBox(height: 12),
// 3. Instrumententyp (Trade Republic typisch)
const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: instrumentType,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
items: const [
DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')),
DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')),
DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
],
onChanged: (val) {
if (val != null) setModalState(() => instrumentType = val);
},
),
const SizedBox(height: 16),
// 4. Anmerkung für die KI
const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
TextField(
controller: notesController,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final payload = ManualAnalysisRequestDto(
isin: widget.symbol,
symbol: widget.symbol,
riskScore: riskScore.toInt(),
minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1,
maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14,
timeframeUnit: timeframeUnit,
instrumentType: instrumentType,
userNotes: notesController.text,
headline: 'Manuelle KI-Analyse für ${widget.symbol}',
);
setState(() {
_justTriggeredAnalysis = true;
});
tradesBloc.add(TriggerManualAnalysis(widget.symbol, payload: payload));
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.flash_on),
label: const Text('Analyse Jetzt Ausführen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradesBloc = context.read<AssetTradesBloc>();
TradeExecutionDialog.show(
context,
trade: trade,
@@ -453,9 +51,7 @@ class _TradesTabState extends State<TradesTab> {
final tId = trade.id;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isActive
? 'Einstellungen für Trade $tId gespeichert!'
: 'Trade $tId angenommen & Position eröffnet!'),
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
@@ -497,7 +93,6 @@ class _TradesTabState extends State<TradesTab> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Action Button & Settings Card
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
@@ -525,7 +120,24 @@ class _TradesTabState extends State<TradesTab> {
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showAnalysisParametersDialog(context),
onPressed: () {
ManualAnalysisDialog.show(
context,
symbol: widget.symbol,
initialRiskScore: _settings.defaultRiskScore,
onTrigger: (payload) {
setState(() => _justTriggeredAnalysis = true);
context.read<AssetTradesBloc>().add(TriggerManualAnalysis(widget.symbol, payload: payload));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
);
},
);
},
icon: const Icon(Icons.auto_awesome, size: 18),
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
@@ -538,7 +150,13 @@ class _TradesTabState extends State<TradesTab> {
),
const SizedBox(width: 10),
IconButton.filledTonal(
onPressed: () => _showLiveTradeSettingsDialog(context),
onPressed: () {
LiveTradeSettingsDialog.show(
context,
currentSettings: _settings,
onSave: (newSettings) => setState(() => _settings = newSettings),
);
},
icon: const Icon(Icons.settings, color: Colors.white),
tooltip: 'Live Trade Einstellungen',
style: IconButton.styleFrom(
@@ -552,7 +170,6 @@ class _TradesTabState extends State<TradesTab> {
),
),
const SizedBox(height: 20),
if (state is AssetTradesLoading)
_buildTradesShimmer(context)
else if (state is AssetTradesError)
@@ -618,313 +235,38 @@ class _TradesTabState extends State<TradesTab> {
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
return _buildRichTradeCard(trade);
final s = trade.status.toUpperCase();
final isActive = s == 'ACTIVE';
return AssetTradeItemCard(
trade: trade,
defaultSymbol: widget.symbol,
onAccept: () => _showEditTradeExecutionDialog(context, trade),
onSettings: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
onClose: isActive
? () {
CloseTradeDialog.show(
context,
trade: trade,
defaultSymbol: widget.symbol,
onClose: (dto) {
final isinVal = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
context.read<AssetTradesBloc>().add(CloseTradeEvent(trade.id, isinVal, dto.userExitPrice));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade ${trade.id} geschlossen! Ausstiegskurs: €${dto.userExitPrice.toStringAsFixed(2)}'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
: null,
);
},
),
],
);
}
Widget _buildRichTradeCard(TradeModel trade) {
final isin = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
final status = trade.status.toUpperCase();
final isBuy = side == 'BUY' || side == 'LONG';
final isActive = status == 'ACTIVE';
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
// AI Execution Plan N8N values
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
final takeProfit = trade.takeProfit;
final takeProfitTargets = trade.takeProfitTargets;
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0) ? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2) : null;
final maxLeverage = trade.maxLeverage;
// Real User Execution Values
final actualEntry = trade.actualEntryPrice;
final posSize = trade.positionSize;
final levUsed = trade.leverageUsed;
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
final entryFee = trade.entryFee;
final exitFee = trade.exitFee;
// Rationale strings
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row: Side, Status, Instrument, Action Buttons cv
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
StatusBadge(label: side, color: sideColor),
const SizedBox(width: 8),
StatusBadge(label: status, color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted)),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
Row(
children: [
if (isActive) ...[
ElevatedButton.icon(
onPressed: () => _showCloseTradeDialog(context, trade),
icon: const Icon(Icons.flag_outlined, size: 14),
label: const Text('Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
ElevatedButton.icon(
onPressed: () => _showEditTradeExecutionDialog(context, trade),
icon: const Icon(Icons.check_circle, size: 14),
label: const Text('Trade Annehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
const SizedBox(height: 12),
// Asset ID & Timeframe Subheader
Text('${trade.companyName.isNotEmpty ? trade.companyName : widget.symbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
const SizedBox(height: 12),
// AI Execution Targets Grid (Entry Zone, SL, TP, CRV, MaxLeverage)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
],
),
if (crv != null || maxLeverage > 0) ...[
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
],
),
],
],
),
),
// Real User Execution Data Section (Actual Entry, Position Size, Leverage Used, Fees, Quantity)
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Tatsächl. Einstieg', '${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
_buildTradeStat('Investition', posSize > 0 ? '${_fmt(posSize)}' : 'N/A', Colors.white),
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
],
),
if (entryFee > 0 || exitFee > 0) ...[
const SizedBox(height: 6),
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
],
),
),
],
// Closed Trade Outcome & Performance Section
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
const SizedBox(height: 12),
Builder(
builder: (context) {
final pnlVal = trade.calculatedPnlAbs;
final pnlPctVal = trade.calculatedPnlPct;
final isWin = pnlVal >= 0;
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
isWin ? Icons.trending_up : Icons.trending_down,
size: 16,
color: color,
),
const SizedBox(width: 6),
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
],
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Ausstiegskurs', 'N/A', Colors.white),
_buildTradeStat(
'Realisierter PnL (€)',
'${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}',
color,
),
_buildTradeStat(
'Rendite (%)',
'${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%',
pnlPctVal >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
],
),
],
),
);
},
),
],
// AI Rationale & Warnings
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 12),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 8),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 8),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 8),
],
if (riskWarning.isNotEmpty)
_buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
),
);
}
Widget _buildTradeStat(String title, String val, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)),
],
);
}
Widget _buildRationaleBlock(String title, String text, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
const SizedBox(height: 2),
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
],
);
}
String _fmt(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? n.toStringAsFixed(2) : val.toString();
}
}
@@ -1,124 +1,25 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../utils/pattern_explanations.dart';
import '../../models/technical_analysis_model.dart';
import 'candlestick_painter.dart';
class CandleModel {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
final double volume;
CandleModel({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
time: DateTime.tryParse(json['timestamp'] ?? json['time'] ?? '') ?? DateTime.now(),
open: (json['open'] ?? 0).toDouble(),
high: (json['high'] ?? 0).toDouble(),
low: (json['low'] ?? 0).toDouble(),
close: (json['close'] ?? 0).toDouble(),
volume: (json['volume'] ?? 0).toDouble(),
);
}
}
class IndicatorModel {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
ema20: json['ema20'] != null ? (json['ema20'] as num).toDouble() : null,
sma50: json['sma50'] != null ? (json['sma50'] as num).toDouble() : null,
sma200: json['sma200'] != null ? (json['sma200'] as num).toDouble() : null,
supertrendUpper: json['supertrendUpper'] != null ? (json['supertrendUpper'] as num).toDouble() : null,
supertrendLower: json['supertrendLower'] != null ? (json['supertrendLower'] as num).toDouble() : null,
supertrendDirection: json['supertrendDirection']?.toString(),
);
}
}
class PatternPoint {
final DateTime time;
final double price;
PatternPoint(this.time, this.price);
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
}
class ChartPatternModel {
final String type;
final List<PatternPoint> upperLine;
final List<PatternPoint> lowerLine;
ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
return ChartPatternModel(
type: json['type'] ?? '',
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
);
}
}
class StrategySignalModel {
final String type;
final DateTime timestamp;
final String direction;
final double price;
final String description;
StrategySignalModel({required this.type, required this.timestamp, required this.direction, required this.price, required this.description});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
return StrategySignalModel(
type: json['type'] ?? '',
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
direction: json['direction'] ?? '',
price: (json['price'] as num).toDouble(),
description: json['description'] ?? '',
);
}
}
export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel;
class CandlestickChart extends StatefulWidget {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showPatterns;
final bool showSignals;
final bool showSupertrend;
final double height;
final bool isFullscreen;
final VoidCallback? onToggleFullscreen;
const CandlestickChart({
super.key,
@@ -126,12 +27,15 @@ class CandlestickChart extends StatefulWidget {
this.patterns = const [],
this.signals = const [],
this.indicators = const [],
this.showPatterns = true,
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showPatterns = true,
this.showSignals = true,
this.showSupertrend = true,
this.height = 420,
this.isFullscreen = false,
this.onToggleFullscreen,
});
@override
@@ -141,81 +45,142 @@ class CandlestickChart extends StatefulWidget {
class _CandlestickChartState extends State<CandlestickChart> {
double _scale = 1.0;
double _panOffset = 0.0;
double _baseScale = 1.0;
double _basePanOffset = 0.0;
Offset _startFocalPoint = Offset.zero;
bool _isDragging = false;
Offset? _tapPosition;
CandleModel? _selectedCandle;
@override
Widget build(BuildContext context) {
if (widget.candles.isEmpty) {
return const Center(child: Text('No chart data'));
}
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitLatestCandles();
});
}
@override
void didUpdateWidget(covariant CandlestickChart oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.candles.length != widget.candles.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitLatestCandles();
});
}
}
void _fitLatestCandles() {
if (widget.candles.isEmpty || !mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
final width = (renderBox?.size.width ?? 600) - 60;
final double candleWidth = 10.0 * _scale;
final double totalCandleSpace = candleWidth + (5.0 * _scale);
final double futureSpace = totalCandleSpace * 10;
final double totalWidth = (widget.candles.length * totalCandleSpace) + futureSpace;
setState(() {
if (totalWidth > width) {
_panOffset = width - totalWidth;
} else {
_panOffset = 0.0;
}
});
}
void _applyZoom(double factor, [double? focalX]) {
if (widget.candles.isEmpty || !mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
final fx = focalX ?? (chartWidth / 2);
setState(() {
final oldScale = _scale;
_scale = (_scale * factor).clamp(0.1, 6.0);
_panOffset = fx - ((fx - _panOffset) * (_scale / oldScale));
_clampPanOffset(chartWidth);
});
}
void _clampPanOffset(double chartWidth) {
if (widget.candles.isEmpty) return;
final double totalCandleSpace = (10.0 + 5.0) * _scale;
final double totalWidth = (widget.candles.length * totalCandleSpace) + (totalCandleSpace * 10);
if (totalWidth <= chartWidth) {
_panOffset = 0.0;
} else {
final double minPan = chartWidth - totalWidth - 30;
const double maxPan = 30.0;
_panOffset = _panOffset.clamp(minPan, maxPan);
}
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return LayoutBuilder(
builder: (context, constraints) {
final double baseWidth = 10.0;
final double spacing = 5.0;
final double totalCandleSpace = (baseWidth + spacing) * _scale;
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
final double maxOffset = 100.0;
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
return Listener(
return Container(
height: widget.height,
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver.register(
pointerSignal,
(event) {
if (event is PointerScrollEvent) {
setState(() {
final double localX = event.localPosition.dx;
final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1;
final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0);
final double scaleRatio = newScale / _scale;
// Zoom centered on cursor
_panOffset = localX - (localX - _panOffset) * scaleRatio;
_scale = newScale;
final double updatedCandleSpace = (baseWidth + spacing) * _scale;
final double updatedContentWidth = (widget.candles.length + 15) * updatedCandleSpace;
final double newMinOffset = constraints.maxWidth - updatedContentWidth - 60.0;
_panOffset = _panOffset.clamp(newMinOffset < maxOffset ? newMinOffset : maxOffset, maxOffset);
});
}
},
);
if (pointerSignal.scrollDelta.dx != 0) {
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
setState(() {
_panOffset -= pointerSignal.scrollDelta.dx;
_clampPanOffset(chartWidth);
});
} else if (pointerSignal.scrollDelta.dy != 0) {
final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85;
_applyZoom(zoomFactor, pointerSignal.localPosition.dx);
}
}
},
child: GestureDetector(
onScaleUpdate: (details) {
setState(() {
_scale = (_scale * details.scale).clamp(0.2, 5.0);
_panOffset += details.focalPointDelta.dx;
_panOffset = _panOffset.clamp(minOffset, maxOffset);
if (_tapPosition != null) {
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
}
});
},
onScaleEnd: (_) => setState(() {
_tapPosition = null;
_selectedCandle = null;
}),
onTapDown: (details) {
_handleTap(details.localPosition, constraints.maxWidth);
},
child: Stack(
children: [
ClipRect(
child: CustomPaint(
child: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: (details) {
_baseScale = _scale;
_basePanOffset = _panOffset;
_startFocalPoint = details.focalPoint;
setState(() => _isDragging = true);
},
onScaleUpdate: (details) {
final renderBox = context.findRenderObject() as RenderBox?;
final chartWidth = (renderBox?.size.width ?? 600) - 60;
setState(() {
if (details.scale != 1.0) {
final oldScale = _scale;
_scale = (_baseScale * details.scale).clamp(0.1, 6.0);
final fx = details.localFocalPoint.dx;
_panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale));
} else {
_panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx);
}
_clampPanOffset(chartWidth);
});
},
onScaleEnd: (details) {
setState(() => _isDragging = false);
},
onTapDown: (details) {
_handleTap(details.localPosition);
},
child: Stack(
children: [
CustomPaint(
size: Size.infinite,
painter: _CandlePainter(
painter: CandlestickPainter(
candles: widget.candles,
patterns: widget.patterns,
signals: widget.signals,
@@ -232,74 +197,76 @@ class _CandlestickChartState extends State<CandlestickChart> {
tapPosition: _tapPosition,
),
),
),
if (_selectedCandle != null) _buildTooltip(theme),
// Floating Zoom & Pan Controls (Top-Left)
Positioned(
left: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 1.25).clamp(0.2, 5.0)),
tooltip: 'Zoom In',
),
IconButton(
icon: const Icon(Icons.zoom_out, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 0.8).clamp(0.2, 5.0)),
tooltip: 'Zoom Out',
),
IconButton(
icon: const Icon(Icons.center_focus_strong, size: 18),
color: theme.textMuted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() {
_scale = 1.0;
_panOffset = 0.0;
}),
tooltip: 'Reset Zoom & Pan',
),
],
),
),
),
],
if (_selectedCandle != null) _buildTooltip(theme),
_buildZoomControls(theme),
],
),
),
),
);
},
),
),
);
}
void _handleTap(Offset pos, double width) {
Widget _buildZoomControls(ThemePreset theme) {
return Positioned(
right: 10,
bottom: 28,
child: Container(
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.glassBorder),
),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildZoomButton(icon: Icons.chevron_left, tooltip: 'Nach links bewegen', onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
setState(() { _panOffset += 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
}),
_buildZoomButton(icon: Icons.chevron_right, tooltip: 'Nach rechts bewegen', onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
setState(() { _panOffset -= 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
}),
Container(width: 1, height: 16, color: theme.glassBorder),
_buildZoomButton(icon: Icons.add, tooltip: 'Vergrößern', onTap: () => _applyZoom(1.25)),
_buildZoomButton(icon: Icons.remove, tooltip: 'Verkleinern', onTap: () => _applyZoom(0.8)),
_buildZoomButton(icon: Icons.fit_screen_outlined, tooltip: 'Aktuelle Kerzen einpassen', onTap: _fitLatestCandles),
_buildZoomButton(icon: Icons.refresh, tooltip: 'Zoom 1:1 zurücksetzen', onTap: () { setState(() => _scale = 1.0); _fitLatestCandles(); }),
if (widget.onToggleFullscreen != null) ...[
Container(width: 1, height: 16, color: theme.glassBorder),
_buildZoomButton(
icon: widget.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
tooltip: widget.isFullscreen ? 'Vollbild beenden' : 'Vollbildmodus (Querformat)',
onTap: widget.onToggleFullscreen!,
),
],
],
),
),
);
}
Widget _buildZoomButton({required IconData icon, required String tooltip, required VoidCallback onTap}) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 16, color: Colors.white70),
),
),
);
}
void _handleTap(Offset pos) {
if (widget.candles.isEmpty) return;
// Right side is for axis, don't tap there
if (pos.dx > width - 60) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * _scale;
final double totalCandleSpace = candleWidth + (spacing * _scale);
// dx = (i * totalCandleSpace) + _panOffset;
// (dx - _panOffset) / totalCandleSpace = i;
final double candleWidth = 10.0 * _scale;
final double totalCandleSpace = candleWidth + (5.0 * _scale);
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
if (index >= 0 && index < widget.candles.length) {
@@ -311,9 +278,9 @@ class _CandlestickChartState extends State<CandlestickChart> {
}
Widget _buildTooltip(ThemePreset theme) {
final candle = _selectedCandle!;
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
final c = _selectedCandle!;
final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}";
return Positioned(
left: 10,
top: 10,
@@ -328,492 +295,12 @@ class _CandlestickChartState extends State<CandlestickChart> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('L: ${candle.low.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('C: ${candle.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${candle.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${c.open.toStringAsFixed(2)} | H: ${c.high.toStringAsFixed(2)} | L: ${c.low.toStringAsFixed(2)} | C: ${c.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)),
],
),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0; // Space for price axis
final double bottomPadding = 20.0; // Space for X-axis labels
_CandlePainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
// Find min/max in view
int firstVisibleIndex = -1;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > 0 && dx < chartWidth) {
if (firstVisibleIndex == -1) firstVisibleIndex = i;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice == 0) return;
// Add 10% padding to top/bottom
final range = maxPrice - minPrice;
maxPrice += range * 0.1;
minPrice -= range * 0.1;
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15; // Bottom 15% for volume
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
_drawGridAndAxis(canvas, size, chartWidth, candleAreaHeight, minPrice, maxPrice, paddedRange);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
// Map DateTime to X for patterns and signals
double getXForTime(DateTime t) {
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].time.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
// Clip to chart area so we don't draw over the axis
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue; // Culling
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
// Draw Wick
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
// Draw Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0); // minimum 1px height
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
// Draw Volume
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
// Indicators mapping by time
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.time) || ind.timestamp.difference(candle.time).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
_drawPatterns(canvas, getXForTime, getYForPrice);
_drawFutureProjectionZone(canvas, size, chartWidth, candleAreaHeight, getXForTime, getYForPrice);
}
if (showSignals) {
_drawSignals(canvas, getXForTime, getYForPrice);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
_drawCrosshair(canvas, size, chartWidth, chartHeight);
}
canvas.restore(); // Restore clip
}
void _drawFutureProjectionZone(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double Function(DateTime) getX, double Function(double) getY) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.time);
if (lastX < chartWidth) {
// 1. Shaded background for Future Zone (No divider line)
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = const Color(0xFF001F3F).withValues(alpha: 0.25)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
// Label for Future Zone
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (MUSTER-SCHÄTZUNG)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
// 2. Projected Ghost Candles & Target Line for active patterns
for (var pattern in patterns) {
if (pattern.lowerLine.isNotEmpty || pattern.upperLine.isNotEmpty) {
final targetPrice = pattern.lowerLine.isNotEmpty ? pattern.lowerLine.last.price : (pattern.upperLine.isNotEmpty ? pattern.upperLine.last.price : 0);
if (targetPrice > 0) {
final targetY = getY(targetPrice.toDouble());
final int numSteps = 10;
final double stepWidth = (chartWidth - lastX - 30) / numSteps;
if (stepWidth <= 0) continue;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? Colors.greenAccent : Colors.redAccent;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + k * stepWidth;
final waveNoise = sin(k * 0.8) * (priceDeltaPerStep.abs() * 0.3);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.2;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.2;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.6, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? Colors.greenAccent : Colors.redAccent;
// Draw Ghost Candle Wick
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.4)..strokeWidth = 1.0,
);
// Draw Ghost Candle Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.0)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
// Target Price Badge at final step
final targetX = currX;
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 2,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 2,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.9));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
}
void _drawGridAndAxis(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double minPrice, double maxPrice, double range) {
final gridPaint = Paint()
..color = theme.glassBorder
..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
// Y Axis
final int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
// X Axis
if (candles.isEmpty) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor(); // label every 80px
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].time;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2,'0')}-${t.day.toString().padLeft(2,'0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
void _drawPatterns(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
for (var pattern in patterns) {
final color = PatternExplanations.getColorForPattern(pattern.type);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
path.moveTo(getX(points[0].time), getY(points[0].price));
for (int i = 1; i < points.length; i++) {
path.lineTo(getX(points[i].time), getY(points[i].price));
}
canvas.drawPath(path, paint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
}
}
void _drawSignals(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
for (var signal in signals) {
final x = getX(signal.timestamp);
final y = getY(signal.price);
final isBuy = signal.direction.toUpperCase() == 'BUY';
final isSell = signal.direction.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
// Draw Pill Badge for Signal
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
// Pill Background
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
// Pointer Line to price point
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
// Text paint
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
void _drawCrosshair(Canvas canvas, Size size, double chartWidth, double chartHeight) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// Vertical
canvas.drawLine(Offset(tapPosition!.dx, 0), Offset(tapPosition!.dx, chartHeight), paint);
// Horizontal
if (tapPosition!.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition!.dy), Offset(chartWidth, tapPosition!.dy), paint);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,292 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import 'chart_overlay_renderer.dart';
class CandlestickPainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0;
final double bottomPadding = 20.0;
CandlestickPainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
int visibleCount = 0;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > -100 && dx < chartWidth + 100) {
visibleCount++;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (visibleCount == 0 || minPrice == double.infinity || maxPrice <= 0) {
for (var c in candles) {
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice <= 0) return;
final range = maxPrice - minPrice;
maxPrice += max(range * 0.1, 1.0);
minPrice -= max(range * 0.1, 1.0);
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15;
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
ChartOverlayRenderer.drawGridAndAxis(
canvas: canvas,
size: size,
chartWidth: chartWidth,
candleAreaHeight: candleAreaHeight,
minPrice: minPrice,
maxPrice: maxPrice,
range: paddedRange,
candles: candles,
scale: scale,
panOffset: panOffset,
bottomPadding: bottomPadding,
theme: theme,
);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
double getXForTime(DateTime t) {
if (candles.isEmpty) return 0.0;
final lastCandle = candles.last;
if (t.isAfter(lastCandle.timestamp) && candles.length > 1) {
final totalSpan = lastCandle.timestamp.difference(candles.first.timestamp).inSeconds;
final secPerCandle = totalSpan / (candles.length - 1);
if (secPerCandle > 0) {
final futureSecs = t.difference(lastCandle.timestamp).inSeconds;
final futureCandles = futureSecs / secPerCandle;
final lastDx = ((candles.length - 1) * totalCandleSpace) + panOffset + candleWidth / 2;
return lastDx + (futureCandles * totalCandleSpace);
}
}
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].timestamp.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue;
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0);
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.timestamp) || ind.timestamp.difference(candle.timestamp).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
ChartOverlayRenderer.drawPatterns(
canvas: canvas,
patterns: patterns,
getX: getXForTime,
getY: getYForPrice,
);
ChartOverlayRenderer.drawFutureProjectionZone(
canvas: canvas,
candles: candles,
patterns: patterns,
chartWidth: chartWidth,
candleAreaHeight: candleAreaHeight,
getX: getXForTime,
getY: getYForPrice,
theme: theme,
);
}
if (showSignals) {
ChartOverlayRenderer.drawSignals(
canvas: canvas,
signals: signals,
getX: getXForTime,
getY: getYForPrice,
theme: theme,
);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
ChartOverlayRenderer.drawCrosshair(
canvas: canvas,
tapPosition: tapPosition!,
chartWidth: chartWidth,
chartHeight: chartHeight,
theme: theme,
);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant CandlestickPainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,331 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/technical_analysis_model.dart';
import '../../utils/pattern_explanations.dart';
/// Helper for rendering chart overlays: grid, axes, patterns, future projections, and signals.
class ChartOverlayRenderer {
static void drawGridAndAxis({
required Canvas canvas,
required Size size,
required double chartWidth,
required double candleAreaHeight,
required double minPrice,
required double maxPrice,
required double range,
required List<CandleModel> candles,
required double scale,
required double panOffset,
required double bottomPadding,
required ThemePreset theme,
}) {
final gridPaint = Paint()..color = theme.glassBorder..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
const int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
if (candles.isEmpty) return;
const double baseWidth = 10.0;
const double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor();
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].timestamp;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
static void drawFutureProjectionZone({
required Canvas canvas,
required List<CandleModel> candles,
required List<ChartPatternModel> patterns,
required double chartWidth,
required double candleAreaHeight,
required double Function(DateTime) getX,
required double Function(double) getY,
required ThemePreset theme,
}) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.timestamp);
if (lastX < chartWidth - 10) {
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = theme.primaryColor.withValues(alpha: 0.05)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
final sepPaint = Paint()
..color = theme.primaryColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawLine(Offset(lastX, 0), Offset(lastX, candleAreaHeight), sepPaint);
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (KI & MUSTER)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
for (var pattern in patterns) {
double targetPrice = 0.0;
if (pattern.breakoutSignal != null && pattern.breakoutSignal!.targetPrice > 0) {
targetPrice = pattern.breakoutSignal!.targetPrice;
} else if (pattern.lowerLine.isNotEmpty && pattern.upperLine.isNotEmpty) {
final diff = (pattern.upperLine.last.price - pattern.lowerLine.last.price).abs();
targetPrice = lastCandle.close >= pattern.lowerLine.last.price
? lastCandle.close + (diff > 0 ? diff : lastCandle.close * 0.05)
: lastCandle.close - (diff > 0 ? diff : lastCandle.close * 0.05);
} else if (pattern.upperLine.isNotEmpty) {
targetPrice = pattern.upperLine.last.price;
} else if (pattern.lowerLine.isNotEmpty) {
targetPrice = pattern.lowerLine.last.price;
}
if (targetPrice > 0) {
final targetY = getY(targetPrice);
const int numSteps = 8;
final double availableWidth = max(chartWidth - lastX - 40, 60.0);
final double stepWidth = availableWidth / numSteps;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + (k * stepWidth);
final waveNoise = sin(k * 0.9) * (priceDeltaPerStep.abs() * 0.25);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.15;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.15;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.55, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.45)..strokeWidth = 1.0,
);
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.5)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
final targetX = currX;
final pct = ((targetPrice - lastCandle.close) / lastCandle.close) * 100;
final pctSign = pct >= 0 ? '+' : '';
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ($pctSign${pct.toStringAsFixed(1)}%) ',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 3,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 3,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.92));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
static void drawPatterns({
required Canvas canvas,
required List<ChartPatternModel> patterns,
required double Function(DateTime) getX,
required double Function(double) getY,
}) {
for (var pattern in patterns) {
final color = PatternExplanations.getColorForPattern(pattern.type);
final paint = Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 2.5;
final fillPaint = Paint()..color = color.withValues(alpha: 0.12)..style = PaintingStyle.fill;
Offset? firstPoint;
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
final startX = getX(points[0].time);
final startY = getY(points[0].price);
path.moveTo(startX, startY);
firstPoint ??= Offset(startX, startY);
for (int i = 1; i < points.length; i++) {
final px = getX(points[i].time);
final py = getY(points[i].price);
path.lineTo(px, py);
}
canvas.drawPath(path, paint);
for (var p in points) {
final px = getX(p.time);
final py = getY(p.price);
canvas.drawCircle(Offset(px, py), 4, Paint()..color = color);
canvas.drawCircle(Offset(px, py), 2, Paint()..color = Colors.white);
}
}
if (pattern.upperLine.length >= 2 && pattern.lowerLine.length >= 2) {
final polyPath = Path();
polyPath.moveTo(getX(pattern.upperLine[0].time), getY(pattern.upperLine[0].price));
for (int i = 1; i < pattern.upperLine.length; i++) {
polyPath.lineTo(getX(pattern.upperLine[i].time), getY(pattern.upperLine[i].price));
}
for (int i = pattern.lowerLine.length - 1; i >= 0; i--) {
polyPath.lineTo(getX(pattern.lowerLine[i].time), getY(pattern.lowerLine[i].price));
}
polyPath.close();
canvas.drawPath(polyPath, fillPaint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
if (firstPoint != null) {
final label = PatternExplanations.getGermanName(pattern.type);
final textPainter = TextPainter(
text: TextSpan(
text: ' $label ',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
)..layout();
final badgeX = firstPoint!.dx;
final badgeY = firstPoint!.dy - 18;
final badgeRect = RRect.fromLTRBR(
badgeX,
badgeY,
badgeX + textPainter.width + 4,
badgeY + textPainter.height + 4,
const Radius.circular(4),
);
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.85));
textPainter.paint(canvas, Offset(badgeX + 2, badgeY + 2));
}
}
}
static void drawSignals({
required Canvas canvas,
required List<StrategySignalModel> signals,
required double Function(DateTime) getX,
required double Function(double) getY,
required ThemePreset theme,
}) {
for (var signal in signals) {
final x = getX(signal.date);
final y = getY(signal.price);
final isBuy = signal.type.toUpperCase() == 'BUY';
final isSell = signal.type.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
final textPainter = TextPainter(
text: TextSpan(text: label, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
static void drawCrosshair({
required Canvas canvas,
required Offset tapPosition,
required double chartWidth,
required double chartHeight,
required ThemePreset theme,
}) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawLine(Offset(tapPosition.dx, 0), Offset(tapPosition.dx, chartHeight), paint);
if (tapPosition.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition.dy), Offset(chartWidth, tapPosition.dy), paint);
}
}
}
@@ -0,0 +1,73 @@
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/fundamental_data_model.dart';
class AnalystPriceTargetCard extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const AnalystPriceTargetCard({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text(
'Analysten-Konsens & Kursziele',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
class CompanyProfileSection extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const CompanyProfileSection({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCompensation(double? val) {
if (val == null || val <= 0) return '---';
if (val >= 1e6) return '$currencySymbol${(val / 1e6).toStringAsFixed(2)}M';
if (val >= 1e3) return '$currencySymbol${(val / 1e3).toStringAsFixed(0)}K';
return '$currencySymbol${val.toStringAsFixed(0)}';
}
String _formatExecutivePayment(CompanyExecutiveModel exec) {
if (exec.compensation != null && exec.compensation! > 0) {
return _fmtCompensation(exec.compensation);
}
if (exec.payment != null && exec.payment!.isNotEmpty) {
final p = exec.payment!.trim();
if (p.startsWith(currencySymbol) || p.startsWith('') || p.startsWith(r'$')) {
return p;
}
final numeric = double.tryParse(p);
if (numeric != null && numeric > 0) {
return _fmtCompensation(numeric);
}
return '$currencySymbol$p';
}
return '---';
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Vollzeitbeschäftigte: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Führungskräfte & Vorstand',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Column(
children: [
for (int i = 0; i < data.executives.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.executives[i].name,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 13),
),
const SizedBox(height: 2),
Text(
data.executives[i].title,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Builder(
builder: (context) {
final payStr = _formatExecutivePayment(data.executives[i]);
if (payStr == '---') return const SizedBox.shrink();
return Text(
payStr,
style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 12),
);
},
),
],
),
),
if (i < data.executives.length - 1) const Divider(color: Colors.white10, height: 1),
],
],
),
),
],
],
);
}
Widget _buildProfileBadge(String text, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(text, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500)),
],
),
);
}
}
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
import '../../utils/metric_explanations.dart';
class _MetricRowItem {
final String label;
final String value;
const _MetricRowItem(this.label, this.value);
}
class FundamentalCategoryPanels extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
final String currencyCode;
const FundamentalCategoryPanels({
super.key,
required this.data,
this.currencySymbol = '\$',
this.currencyCode = 'USD',
});
String _formatNumber(double? number) {
if (number == null) return 'N/A';
final abs = number.abs();
final sign = number < 0 ? '-' : '';
if (abs >= 1e12) return '$sign$currencySymbol${(abs / 1e12).toStringAsFixed(2)} Tsd. Mrd. $currencyCode';
if (abs >= 1e9) return '$sign$currencySymbol${(abs / 1e9).toStringAsFixed(2)} Mrd. $currencyCode';
if (abs >= 1e6) return '$sign$currencySymbol${(abs / 1e6).toStringAsFixed(2)} Mio. $currencyCode';
return '$sign$currencySymbol${NumberFormat("#,##0.00", "de_DE").format(abs)} $currencyCode';
}
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
String _fmtMultiple(double? val) {
if (val == null) return 'N/A';
return '${val.toStringAsFixed(2)}x';
}
String _fmtPercent(double? val) {
if (val == null) return 'N/A';
final p = (val.abs() <= 1.0 && val != 0.0) ? val * 100.0 : val;
return '${p.toStringAsFixed(2)}%';
}
String _fmtDebtToEquity(double? val) {
if (val == null) return 'N/A';
final p = val > 10.0 ? val : val * 100.0;
return '${p.toStringAsFixed(1)}%';
}
String _fmtDate(String? raw) {
if (raw == null || raw.isEmpty) return 'N/A';
final dt = DateTime.tryParse(raw);
if (dt == null) return raw;
return DateFormat('dd.MM.yyyy').format(dt);
}
@override
Widget build(BuildContext context) {
final valuationItems = [
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)),
_MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)),
_MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)),
_MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
];
final profitabilityItems = [
_MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)),
_MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)),
_MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)),
_MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_MetricRowItem('EBITDA', _formatNumber(data.ebitda)),
_MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)),
_MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)),
_MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)),
_MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)),
_MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)),
_MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)),
_MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)),
];
final dividendItems = [
_MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)),
_MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'),
_MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
];
final panel1 = _buildCategoryPanel(
context: context,
title: 'Bewertungskennzahlen & Multiples',
icon: Icons.analytics_outlined,
items: valuationItems,
);
final panel2 = _buildCategoryPanel(
context: context,
title: 'Rentabilität & Finanzen',
icon: Icons.account_balance_outlined,
items: profitabilityItems,
);
final panel3 = _buildCategoryPanel(
context: context,
title: 'Dividenden & Termine',
icon: Icons.pie_chart_outline,
items: dividendItems,
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1050) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 14),
Expanded(child: panel2),
const SizedBox(width: 14),
Expanded(child: panel3),
],
);
} else if (constraints.maxWidth >= 680) {
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: panel1),
const SizedBox(width: 12),
Expanded(child: panel2),
],
),
const SizedBox(height: 12),
panel3,
],
);
} else {
return Column(
children: [
panel1,
const SizedBox(height: 12),
panel2,
const SizedBox(height: 12),
panel3,
],
);
}
},
);
}
Widget _buildCategoryPanel({
required BuildContext context,
required String title,
required IconData icon,
required List<_MetricRowItem> items,
}) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: AppTheme.primaryEmerald, size: 16),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 10),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 4),
for (int i = 0; i < items.length; i++) ...[
_buildMetricTile(context, items[i].label, items[i].value, isEven: i.isEven),
],
],
),
);
}
Widget _buildMetricTile(BuildContext context, String label, String value, {bool isEven = false}) {
final hasExplanation = MetricExplanations.hasExplanation(label);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
if (hasExplanation) ...[
const SizedBox(width: 4),
InkWell(
onTap: () => MetricExplanations.showModal(context, label),
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 12),
),
],
),
);
}
}
@@ -3,11 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/asset_logo_widget.dart';
import '../../../../shared/widgets/favorite_star_button.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../models/asset_model.dart';
import '../../models/fundamental_data_model.dart';
import 'package:url_launcher/url_launcher.dart';
class AssetHeroHeader extends StatelessWidget {
@@ -20,35 +20,31 @@ class AssetHeroHeader extends StatelessWidget {
const AssetHeroHeader({
super.key,
this.onExchangeChanged,
this.onForceRefresh, required this.isin, required this.name, this.symbol,
this.onForceRefresh,
required this.isin,
required this.name,
this.symbol,
});
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocBuilder<AssetHeaderBloc, AssetHeaderState>(
builder: (context, state) {
double? price;
String currency = 'EUR';
List<AssetTickerOption> tickerOptions = [
AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0)
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, fundState) {
String displayName = name;
final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null;
List<TickerModel> tickerOptions = [
TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null)
];
AssetModel? asset;
if (state is AssetHeaderLoaded) {
asset = state.data;
} else if (state is AssetHeaderLoading) {
asset = state.previousData;
}
if (asset != null) {
//name = asset.name.isNotEmpty ? asset.name : symbol;
currency = asset.currency.isNotEmpty ? asset.currency : 'EUR';
price = asset.currentPrice;
if (asset.tickers.isNotEmpty) {
tickerOptions = asset.tickers;
if (fundState is AssetFundamentalsLoaded && fundState.data != null) {
final data = fundState.data!;
if (data.companyName.isNotEmpty) {
displayName = data.companyName;
}
if (data.availableTickers.isNotEmpty) {
tickerOptions = data.availableTickers;
}
}
@@ -88,14 +84,14 @@ class AssetHeroHeader extends StatelessWidget {
),
const SizedBox(width: 8),
],
AssetLogoWidget(symbolOrName: isin, imageUrl: asset?.image, size: 48),
AssetLogoWidget(symbolOrName: isin, imageUrl: logoUrl, size: 48),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
name,
displayName,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
@@ -138,7 +134,7 @@ class AssetHeroHeader extends StatelessWidget {
onPressed: onForceRefresh,
),
const SizedBox(width: 8),
FavoriteStarButton(symbol: symbol, identifier: isin, name: name),
FavoriteStarButton(symbol: symbol, identifier: isin, name: displayName),
],
),
],
@@ -150,10 +146,8 @@ class AssetHeroHeader extends StatelessWidget {
children: [
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, taState) {
double? livePrice = price;
String liveCurrency = selectedOption.tradingCurrency.isNotEmpty
? selectedOption.tradingCurrency
: currency;
double? livePrice = selectedOption.currentPrice;
String liveCurrency = selectedOption.tradingCurrency ?? 'EUR';
if (taState is AssetTechnicalLoaded && taState.data != null) {
if (taState.data!.candles.isNotEmpty) {
@@ -219,12 +213,12 @@ class AssetHeroHeader extends StatelessWidget {
(t) => t.ticker == newTicker,
orElse: () => tickerOptions.first,
);
onExchangeChanged!(opt.exchange, opt.ticker);
onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker);
}
},
itemBuilder: (context) {
return tickerOptions.map((opt) {
final ex = opt.exchange;
final ex = opt.exchange ?? 'Unknown';
final tick = opt.ticker;
final label = '$tick ($ex)';
final isSelected = tick == symbol || ex == symbol;
@@ -263,7 +257,7 @@ class AssetHeroHeader extends StatelessWidget {
Icon(Icons.business, size: 14, color: theme.accentColor),
const SizedBox(width: 6),
Text(
'${selectedOption.ticker} (${selectedOption.exchange})',
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
@@ -285,3 +279,4 @@ class AssetHeroHeader extends StatelessWidget {
);
}
}
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../utils/metric_explanations.dart';
class IndicatorRibbonBar extends StatelessWidget {
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSupertrend;
final bool showPatterns;
final bool showSignals;
final ValueChanged<bool> onToggleSma50;
final ValueChanged<bool> onToggleSma200;
final ValueChanged<bool> onToggleEma;
final ValueChanged<bool> onToggleSupertrend;
final ValueChanged<bool> onTogglePatterns;
final ValueChanged<bool> onToggleSignals;
final VoidCallback? onToggleFullscreen;
final bool isFullscreen;
const IndicatorRibbonBar({
super.key,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSupertrend,
required this.showPatterns,
required this.showSignals,
required this.onToggleSma50,
required this.onToggleSma200,
required this.onToggleEma,
required this.onToggleSupertrend,
required this.onTogglePatterns,
required this.onToggleSignals,
this.onToggleFullscreen,
this.isFullscreen = false,
});
Widget _buildChip(BuildContext context, String label, bool isSelected, ValueChanged<bool> onChanged, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
selected: isSelected,
label: Text(
label,
style: TextStyle(
color: isSelected ? Colors.black : color,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
side: BorderSide(color: color.withValues(alpha: 0.4)),
showCheckmark: false,
onSelected: onChanged,
),
InkWell(
onTap: () => MetricExplanations.show(context, label),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildChip(context, 'SMA 50', showSma50, onToggleSma50, AppTheme.accentCyan),
const SizedBox(width: 8),
_buildChip(context, 'SMA 200', showSma200, onToggleSma200, Colors.amber),
const SizedBox(width: 8),
_buildChip(context, 'EMA 20', showEma, onToggleEma, Colors.purpleAccent),
const SizedBox(width: 8),
_buildChip(context, 'Supertrend', showSupertrend, onToggleSupertrend, AppTheme.primaryEmerald),
const SizedBox(width: 8),
_buildChip(context, 'Muster', showPatterns, onTogglePatterns, Colors.orangeAccent),
const SizedBox(width: 8),
_buildChip(context, 'Signale', showSignals, onToggleSignals, Colors.greenAccent),
if (onToggleFullscreen != null) ...[
const SizedBox(width: 12),
Container(width: 1, height: 20, color: AppTheme.activePreset.glassBorder),
const SizedBox(width: 12),
InkWell(
onTap: onToggleFullscreen,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.activePreset.cardSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.activePreset.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
size: 16,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
isFullscreen ? 'Normal' : 'Vollbild',
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../models/technical_analysis_model.dart';
import '../../utils/pattern_explanations.dart';
class PatternCardItem extends StatelessWidget {
final ChartPatternModel pattern;
final int index;
final bool isEnabled;
final ValueChanged<bool> onToggle;
const PatternCardItem({
super.key,
required this.pattern,
required this.index,
required this.isEnabled,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
DateTime? startDate;
DateTime? endDate;
if (allPoints.isNotEmpty) {
allPoints.sort((a, b) => a.time.compareTo(b.time));
startDate = allPoints.first.time;
endDate = allPoints.last.time;
}
final dateFormat = DateFormat('dd.MM.yy');
final dateStr = startDate != null && endDate != null
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
: 'Unbekannt';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Checkbox(
value: isEnabled,
activeColor: patternColor,
checkColor: Colors.black,
side: BorderSide(color: patternColor.withValues(alpha: 0.6)),
onChanged: (bool? val) => onToggle(val ?? false),
),
Expanded(
child: InkWell(
onTap: () => PatternExplanations.showPatternDetails(context, pattern.type),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled ? patternColor.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.polyline_outlined,
color: isEnabled ? patternColor : AppTheme.textMuted,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pattern.type,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isEnabled ? Colors.white : AppTheme.textMuted,
fontSize: 14,
decoration: isEnabled ? null : TextDecoration.lineThrough,
),
),
const SizedBox(width: 6),
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
],
),
Text(
'Zeitraum: $dateStr\n'
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, height: 1.3),
),
],
),
),
StatusBadge(
label: isEnabled ? 'AKTIV' : 'AUS',
color: isEnabled ? patternColor : AppTheme.textMuted,
),
],
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,63 @@
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/technical_analysis_model.dart';
class SignalCardItem extends StatelessWidget {
final StrategySignalModel signal;
const SignalCardItem({super.key, required this.signal});
@override
Widget build(BuildContext context) {
final isBuy = signal.type.toUpperCase() == 'BUY';
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
signal.type.toUpperCase(),
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14),
),
const SizedBox(width: 8),
Text(
'@ €${signal.price.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
const SizedBox(height: 4),
Text(
'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
),
],
),
),
StatusBadge(label: 'SIGNAL', color: color),
],
),
),
);
}
}
@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../trades/models/trade_model.dart';
class AssetTradeItemCard extends StatelessWidget {
final TradeModel trade;
final String defaultSymbol;
final VoidCallback? onAccept;
final VoidCallback? onSettings;
final VoidCallback? onClose;
const AssetTradeItemCard({
super.key,
required this.trade,
required this.defaultSymbol,
this.onAccept,
this.onSettings,
this.onClose,
});
String _fmt(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? n.toStringAsFixed(2) : val.toString();
}
@override
Widget build(BuildContext context) {
final isin = trade.isin.isNotEmpty ? trade.isin : defaultSymbol;
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
final status = trade.status.toUpperCase();
final isBuy = side == 'BUY' || side == 'LONG';
final isActive = status == 'ACTIVE';
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
final takeProfit = trade.takeProfit;
final takeProfitTargets = trade.takeProfitTargets;
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0)
? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2)
: null;
final maxLeverage = trade.maxLeverage;
final actualEntry = trade.actualEntryPrice;
final posSize = trade.positionSize;
final levUsed = trade.leverageUsed;
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
final entryFee = trade.entryFee;
final exitFee = trade.exitFee;
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
StatusBadge(label: side, color: sideColor),
const SizedBox(width: 8),
StatusBadge(
label: status,
color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted),
),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
Row(
children: [
if (isActive) ...[
if (onClose != null)
ElevatedButton.icon(
onPressed: onClose,
icon: const Icon(Icons.flag_outlined, size: 14),
label: const Text('Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
if (onSettings != null)
IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
if (onAccept != null)
ElevatedButton.icon(
onPressed: onAccept,
icon: const Icon(Icons.check_circle, size: 14),
label: const Text('Trade Annehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
const SizedBox(height: 12),
Text(
'${trade.companyName.isNotEmpty ? trade.companyName : defaultSymbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
],
),
if (crv != null || maxLeverage > 0) ...[
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
],
),
],
],
),
),
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Tatsächl. Einstieg', '${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
_buildTradeStat('Investition', posSize > 0 ? '${_fmt(posSize)}' : 'N/A', Colors.white),
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
],
),
if (entryFee > 0 || exitFee > 0) ...[
const SizedBox(height: 6),
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
],
),
),
],
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
const SizedBox(height: 12),
Builder(
builder: (context) {
final pnlVal = trade.calculatedPnlAbs;
final pnlPctVal = trade.calculatedPnlPct;
final isWin = pnlVal >= 0;
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(isWin ? Icons.trending_up : Icons.trending_down, size: 16, color: color),
const SizedBox(width: 6),
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Ausstiegskurs', trade.actualExitPrice > 0 ? '${_fmt(trade.actualExitPrice)}' : 'N/A', Colors.white),
_buildTradeStat('Realisierter PnL (€)', '${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}', color),
_buildTradeStat('Rendite (%)', '${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%', isWin ? AppTheme.primaryEmerald : AppTheme.accentRed),
],
),
],
),
);
},
),
],
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 12),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 8),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 8),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 8),
],
if (riskWarning.isNotEmpty) _buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
),
);
}
Widget _buildTradeStat(String title, String val, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)),
],
);
}
Widget _buildRationaleBlock(String title, String text, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
const SizedBox(height: 2),
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
],
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../trades/models/trade_model.dart';
import '../../../trades/models/close_trade_request_dto.dart';
class CloseTradeDialog {
static void show(
BuildContext context, {
required TradeModel trade,
required String defaultSymbol,
required void Function(CloseTradeRequestDto) onClose,
}) {
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22),
const SizedBox(width: 8),
const Expanded(
child: Text('Trade Position Schließen', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Z.B. 105.50',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final exitPrice = double.tryParse(exitController.text) ?? entry;
Navigator.pop(dialogContext);
onClose(CloseTradeRequestDto(userExitPrice: exitPrice));
},
icon: const Icon(Icons.check),
label: const Text('Position Schließen & Buchen'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentRed, foregroundColor: Colors.white),
),
],
);
},
);
}
}
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class LiveTradeSettings {
final double defaultPositionSize;
final double defaultLeverage;
final double defaultRiskScore;
final double defaultOrderFee;
final bool autoAcceptSignals;
const LiveTradeSettings({
required this.defaultPositionSize,
required this.defaultLeverage,
required this.defaultRiskScore,
required this.defaultOrderFee,
required this.autoAcceptSignals,
});
}
class LiveTradeSettingsDialog {
static void show(
BuildContext context, {
required LiveTradeSettings currentSettings,
required ValueChanged<LiveTradeSettings> onSave,
}) {
double tempPos = currentSettings.defaultPositionSize;
double tempLev = currentSettings.defaultLeverage;
double tempRisk = currentSettings.defaultRiskScore;
double tempFee = currentSettings.defaultOrderFee;
bool tempAuto = currentSettings.autoAcceptSignals;
final posController = TextEditingController(text: tempPos.toStringAsFixed(0));
final levController = TextEditingController(text: tempLev.toStringAsFixed(1));
final feeController = TextEditingController(text: tempFee.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.settings, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Live Trade Einstellungen',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
content: SizedBox(
width: 440,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Standard Trade-Vorgaben für Ihr Depot:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: posController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Investment (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempPos = double.tryParse(v) ?? tempPos,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: levController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempLev = double.tryParse(v) ?? tempLev,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: feeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Ordergebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempFee = double.tryParse(v) ?? tempFee,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Standard Risiko-Toleranz:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text('${tempRisk.toInt()}/100', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
Slider(
value: tempRisk,
min: 0,
max: 100,
divisions: 100,
activeColor: AppTheme.primaryEmerald,
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => tempRisk = val),
),
const SizedBox(height: 10),
SwitchListTile(
value: tempAuto,
activeThumbColor: AppTheme.primaryEmerald,
title: const Text('KI-Signale automatisch annehmen', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
subtitle: Text('Führt eingehende Signale direkt im Depot aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
onChanged: (val) => setModalState(() => tempAuto = val),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
onSave(LiveTradeSettings(
defaultPositionSize: tempPos,
defaultLeverage: tempLev,
defaultRiskScore: tempRisk,
defaultOrderFee: tempFee,
autoAcceptSignals: tempAuto,
));
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Live Trade Einstellungen gespeichert.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.save),
label: const Text('Einstellungen Speichern'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black),
),
],
);
},
);
},
);
}
}
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../models/manual_analysis_request_dto.dart';
class ManualAnalysisDialog {
static void show(
BuildContext context, {
required String symbol,
required double initialRiskScore,
required void Function(ManualAnalysisRequestDto) onTrigger,
}) {
double riskScore = initialRiskScore;
final minTimeframeController = TextEditingController(text: '1');
final maxTimeframeController = TextEditingController(text: '14');
String timeframeUnit = 'Tage';
String instrumentType = 'Knock-Out Zertifikat (Turbo)';
final notesController = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('KI-Analyse für $symbol', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 440,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Wählen Sie Ihre Zielparameter für die Trade-Evaluierung:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
const Text('Zeithorizont (Timeframe):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: TextField(
controller: minTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: maxTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: timeframeUnit,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [
DropdownMenuItem(value: 'Stunden', child: Text('Stunden')),
DropdownMenuItem(value: 'Tage', child: Text('Tage')),
DropdownMenuItem(value: 'Wochen', child: Text('Wochen')),
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
],
onChanged: (val) {
if (val != null) setModalState(() => timeframeUnit = val);
},
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text(
'${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
style: TextStyle(
color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
Slider(
value: riskScore,
min: 0,
max: 100,
divisions: 100,
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => riskScore = val),
),
const SizedBox(height: 12),
const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: instrumentType,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
items: const [
DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')),
DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')),
DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
],
onChanged: (val) {
if (val != null) setModalState(() => instrumentType = val);
},
),
const SizedBox(height: 16),
const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
TextField(
controller: notesController,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final payload = ManualAnalysisRequestDto(
isin: symbol,
symbol: symbol,
riskScore: riskScore.toInt(),
minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1,
maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14,
timeframeUnit: timeframeUnit,
instrumentType: instrumentType,
userNotes: notesController.text,
headline: 'Manuelle KI-Analyse für $symbol',
);
Navigator.pop(dialogContext);
onTrigger(payload);
},
icon: const Icon(Icons.flash_on),
label: const Text('Analyse Jetzt Ausführen'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black),
),
],
);
},
);
},
);
}
}
@@ -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),
],
),
);
}
}
+96
View File
@@ -33,6 +33,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819"
url: "https://pub.dev"
source: hosted
version: "3.4.0"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
characters:
dependency: transitive
description:
@@ -158,6 +182,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.6"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
flutter_lints:
dependency: "direct dev"
description:
@@ -384,6 +416,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.4.1"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
@@ -512,6 +552,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@@ -597,6 +645,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
url: "https://pub.dev"
source: hosted
version: "2.4.3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
url: "https://pub.dev"
source: hosted
version: "2.5.11"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
url: "https://pub.dev"
source: hosted
version: "2.4.3+1"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sse:
dependency: transitive
description:
@@ -637,6 +725,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423"
url: "https://pub.dev"
source: hosted
version: "3.4.1+1"
term_glyph:
dependency: transitive
description:
+1
View File
@@ -22,6 +22,7 @@ dependencies:
cupertino_icons: ^1.0.6
url_launcher: ^6.3.2
flutter_svg: ^2.0.9
cached_network_image: ^3.3.1
dev_dependencies:
flutter_test:
@@ -85,13 +85,12 @@ public class CalendarController : ControllerBase
}
return new CalendarEventResponseDto(
Id: $"evt-{eventIsin}-{eventDate:yyyyMMdd}-{eventType}",
Symbol: string.IsNullOrWhiteSpace(ticker) ? eventIsin : ticker,
Id: e.Id.ToString(),
CompanyName: companyName,
EventType: eventType,
EventDate: eventDate,
Date: eventDate.ToString("o"),
Isin: eventIsin,
Isin: e.Isin,
Ticker: ticker,
Description: $"{eventType} - {companyName}",
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
@@ -119,9 +118,7 @@ public class CalendarController : ControllerBase
{
filtered = filtered.Where(e =>
string.Equals(e.Isin, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
string.Equals(e.Ticker, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
string.Equals(e.Symbol, activeSymbol, StringComparison.OrdinalIgnoreCase)
);
string.Equals(e.Ticker, activeSymbol, StringComparison.OrdinalIgnoreCase));
}
return Ok(filtered.OrderBy(e => e.EventDate).ToList());
@@ -8,7 +8,6 @@ namespace FinlyticCore.Dtos;
/// </summary>
public record CalendarEventResponseDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("companyName")] string CompanyName,
[property: JsonPropertyName("eventType")] string EventType,
[property: JsonPropertyName("eventDate")] DateTime EventDate,
@@ -477,9 +477,11 @@ public class FundamentalsDbService : IFundamentalsDbService
return events.Select(e => new CorporateEventDto
{
Id = e.Id,
Isin = e.AssetData.Isin,
Ticker = e.Ticker != null
? new TickerInfoDto { Ticker = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) }
: new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" },
CompanyName = e.AssetData.Name,
Type = e.Type,
Date = e.Date
}).OrderBy(e => e.Date).ToList();
@@ -504,6 +506,8 @@ public class FundamentalsDbService : IFundamentalsDbService
return events.Select(e => new CorporateEventDto
{
Id = e.Id,
Isin = e.AssetData.Isin,
CompanyName = e.AssetData.Name,
Ticker = e.Ticker != null
? new TickerInfoDto { Ticker = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) }
: new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" },