Compare commits
4 Commits
a3f9e55a7e
...
5a6a50a609
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a6a50a609 | |||
| 1ccb6b613f | |||
| 15f8f7896e | |||
| f08fecde23 |
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+98
-121
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+100
-115
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+161
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+263
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user