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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
@@ -24,7 +25,13 @@ class AssetLogoWidget extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
|
// Resolve relative URLs (e.g. /api/v1/logo/...) to include host and port (e.g. http://localhost:5000)
|
||||||
String? resolveUrl(String? url) {
|
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;
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
|
return url.startsWith('/') ? '${ApiClient.baseUrl}$url' : '${ApiClient.baseUrl}/$url';
|
||||||
}
|
}
|
||||||
@@ -65,12 +72,13 @@ class AssetLogoWidget extends StatelessWidget {
|
|||||||
return _buildFallback(initial, colors);
|
return _buildFallback(initial, colors);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: Image.network(
|
: CachedNetworkImage(
|
||||||
image,
|
imageUrl: image,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
placeholder: (context, url) => _buildFallback(initial, colors),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
_failedUrls.add(image);
|
_failedUrls.add(image);
|
||||||
return _buildFallback(initial, colors);
|
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_user_model.dart';
|
||||||
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.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/admin_update_user_request_dto.dart';
|
||||||
|
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
|
||||||
|
|
||||||
class AdminRepository {
|
class AdminRepository {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
AdminRepository({required this.apiClient});
|
const AdminRepository({required this.apiClient});
|
||||||
|
|
||||||
Future<List<AdminUserModel>> fetchUsers() async {
|
Future<List<AdminUserModel>> fetchUsers() async {
|
||||||
try {
|
try {
|
||||||
@@ -17,8 +18,7 @@ class AdminRepository {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error fetching admin users: $e');
|
throw Exception('Nutzer konnten nicht geladen werden: $e');
|
||||||
throw Exception('Nutzer konnten nicht geladen werden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,4 +35,29 @@ class AdminRepository {
|
|||||||
throw Exception('Aktualisieren fehlgeschlagen');
|
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/network/signalr_service.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/status_badge.dart';
|
|
||||||
import '../models/admin_update_user_request_dto.dart';
|
import '../models/admin_update_user_request_dto.dart';
|
||||||
import '../models/admin_user_model.dart';
|
import '../models/admin_user_model.dart';
|
||||||
import '../bloc/admin_bloc.dart';
|
import '../bloc/admin_bloc.dart';
|
||||||
import '../repositories/admin_repository.dart';
|
import '../repositories/admin_repository.dart';
|
||||||
import '../widgets/admin_kpi_header.dart';
|
import '../widgets/admin_kpi_header.dart';
|
||||||
|
import '../widgets/admin_user_card_item.dart';
|
||||||
import '../widgets/create_user_dialog.dart';
|
import '../widgets/create_user_dialog.dart';
|
||||||
import '../widgets/edit_user_dialog.dart';
|
import '../widgets/edit_user_dialog.dart';
|
||||||
|
|
||||||
import '../widgets/system_diagnostics_widget.dart';
|
import '../widgets/system_diagnostics_widget.dart';
|
||||||
|
|
||||||
/// Role-Restricted Admin Panel Screen managing users, service settings, and system health.
|
|
||||||
class AdminUsersScreen extends StatelessWidget {
|
class AdminUsersScreen extends StatelessWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
final SignalRService? signalRService;
|
final SignalRService? signalRService;
|
||||||
@@ -102,61 +100,52 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Top Header Ribbon
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Text(
|
||||||
children: [
|
'Administration & System',
|
||||||
Container(
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
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 SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Zentrales Management für Nutzer, Mikrodienste & MQTT System-Bus',
|
'Finlytic Admin-Dashboard • Microservices & Benutzer',
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
Row(
|
||||||
onPressed: () => _openCreateUser(context),
|
children: [
|
||||||
icon: const Icon(Icons.person_add_outlined, size: 18),
|
IconButton(
|
||||||
label: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
|
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
|
||||||
style: ElevatedButton.styleFrom(
|
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
tooltip: 'Neu laden',
|
||||||
foregroundColor: Colors.black,
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
const SizedBox(width: 8),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// KPI Header Metrics
|
|
||||||
AdminKpiHeader(
|
AdminKpiHeader(
|
||||||
users: users,
|
users: users,
|
||||||
signalRService: widget.signalRService,
|
signalRService: widget.signalRService,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Tab Selector Ribbon
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.glassSurface,
|
color: AppTheme.glassSurface,
|
||||||
@@ -182,19 +171,12 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Tab Content View
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
// Tab 1: User Management with Filter & Actions
|
|
||||||
_buildUserManagementTab(context, state, users),
|
_buildUserManagementTab(context, state, users),
|
||||||
|
SystemDiagnosticsWidget(signalRService: widget.signalRService),
|
||||||
// Tab 2: System Diagnostics & Microservices Health
|
|
||||||
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 filteredUsers = allUsers.where((u) {
|
||||||
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||||
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
|
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||||
@@ -243,7 +224,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Filter Bar (Search Field & Role Filter Chips)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -292,8 +272,6 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
// User Cards List
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: filteredUsers.isEmpty
|
child: filteredUsers.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
@@ -302,10 +280,7 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
|
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text('Keine passenden Benutzer gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 14)),
|
||||||
'Keine passenden Benutzer gefunden.',
|
|
||||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -313,95 +288,10 @@ class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with
|
|||||||
itemCount: filteredUsers.length,
|
itemCount: filteredUsers.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final u = filteredUsers[index];
|
final u = filteredUsers[index];
|
||||||
final String role = u.role;
|
return AdminUserCardItem(
|
||||||
final bool isActive = u.isActive;
|
user: u,
|
||||||
|
onToggleActive: (val) => _toggleUserActiveStatus(context, u, val),
|
||||||
final Color roleColor = role == 'Admin'
|
onEdit: () => _openEditUser(context, u),
|
||||||
? 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/status_badge.dart';
|
import '../../../core/widgets/status_badge.dart';
|
||||||
|
import '../models/service_setting_dto.dart';
|
||||||
|
import '../repositories/admin_repository.dart';
|
||||||
|
|
||||||
class ServiceDetailScreen extends StatefulWidget {
|
class ServiceDetailScreen extends StatefulWidget {
|
||||||
final String serviceName;
|
final String serviceName;
|
||||||
final ApiClient apiClient;
|
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
|
@override
|
||||||
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
|
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||||
|
late final AdminRepository _repository;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
String _error = '';
|
String _error = '';
|
||||||
List<dynamic> _settings = [];
|
List<ServiceSettingDto> _settings = [];
|
||||||
final Map<String, TextEditingController> _controllers = {};
|
final Map<String, TextEditingController> _controllers = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_repository = widget.repository ?? AdminRepository(apiClient: widget.apiClient);
|
||||||
_fetchServiceDetails();
|
_fetchServiceDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,27 +48,22 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
|
|
||||||
Future<void> _fetchServiceDetails() async {
|
Future<void> _fetchServiceDetails() async {
|
||||||
try {
|
try {
|
||||||
final res = await widget.apiClient.get('/api/v1/admin/settings');
|
final groupedSettings = await _repository.fetchSettings();
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
|
||||||
final groupedSettings = res.data as Map<String, dynamic>;
|
|
||||||
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_settings = serviceSettings;
|
_settings = serviceSettings;
|
||||||
for (var s in _settings) {
|
for (var s in _settings) {
|
||||||
final key = s['key']?.toString() ?? '';
|
final key = s.key;
|
||||||
final val = s['value']?.toString() ?? '';
|
final val = s.value;
|
||||||
if (!_controllers.containsKey(key)) {
|
if (!_controllers.containsKey(key)) {
|
||||||
_controllers[key] = TextEditingController(text: val);
|
_controllers[key] = TextEditingController(text: val);
|
||||||
} else {
|
} else {
|
||||||
_controllers[key]!.text = val;
|
_controllers[key]!.text = val;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_isLoading = false;
|
}
|
||||||
});
|
_isLoading = false;
|
||||||
} else {
|
});
|
||||||
throw Exception('Failed to load settings');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
@@ -75,35 +80,28 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
payload[k] = v.text;
|
payload[k] = v.text;
|
||||||
});
|
});
|
||||||
|
|
||||||
final res = await widget.apiClient.put(
|
await _repository.updateServiceSettings(widget.serviceName, payload);
|
||||||
'/api/v1/admin/settings/${widget.serviceName}',
|
|
||||||
data: payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.statusCode == 200 || res.statusCode == 204) {
|
if (mounted) {
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(
|
||||||
SnackBar(
|
content: Row(
|
||||||
content: Row(
|
children: [
|
||||||
children: [
|
const Icon(Icons.check_circle_outline, color: Colors.black),
|
||||||
const Icon(Icons.check_circle_outline, color: Colors.black),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Expanded(
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Text(
|
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
|
||||||
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
|
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
|
||||||
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
),
|
||||||
);
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
}
|
behavior: SnackBarBehavior.floating,
|
||||||
} else {
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
throw Exception('Server returned status code ${res.statusCode}');
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -170,8 +168,8 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
const Text('Keine spezifischen Einstellungen gefunden.')
|
const Text('Keine spezifischen Einstellungen gefunden.')
|
||||||
else
|
else
|
||||||
..._settings.map((s) {
|
..._settings.map((s) {
|
||||||
final key = s['key']?.toString() ?? '';
|
final key = s.key;
|
||||||
final desc = s['description']?.toString() ?? '';
|
final desc = s.description;
|
||||||
final controller = _controllers[key];
|
final controller = _controllers[key];
|
||||||
if (controller == null) return const SizedBox.shrink();
|
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/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../repositories/admin_repository.dart';
|
||||||
import '../../../core/widgets/status_badge.dart';
|
import 'service_settings_form.dart';
|
||||||
|
|
||||||
/// Service Metadata Info used for Admin Config Navigation
|
|
||||||
class ServiceConfigMeta {
|
class ServiceConfigMeta {
|
||||||
final String key;
|
final String key;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
@@ -21,17 +21,18 @@ class ServiceConfigMeta {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Centralized Service Configuration Management Widget for Admin Panel.
|
|
||||||
class PipelineSettingsWidget extends StatefulWidget {
|
class PipelineSettingsWidget extends StatefulWidget {
|
||||||
final ApiClient? apiClient;
|
final ApiClient? apiClient;
|
||||||
|
final AdminRepository? repository;
|
||||||
|
|
||||||
const PipelineSettingsWidget({super.key, this.apiClient});
|
const PipelineSettingsWidget({super.key, this.apiClient, this.repository});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
|
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||||
|
late final AdminRepository _repository;
|
||||||
String _selectedServiceKey = 'FinlyticAssets';
|
String _selectedServiceKey = 'FinlyticAssets';
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
@@ -132,39 +133,34 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void didChangeDependencies() {
|
||||||
super.initState();
|
super.didChangeDependencies();
|
||||||
_fetchSettings();
|
if (!_initialized) {
|
||||||
|
_initialized = true;
|
||||||
|
final client = widget.apiClient ?? context.read<ApiClient>();
|
||||||
|
_repository = widget.repository ?? AdminRepository(apiClient: client);
|
||||||
|
_fetchSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchSettings() async {
|
Future<void> _fetchSettings() async {
|
||||||
if (widget.apiClient == null) return;
|
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final res = await widget.apiClient!.get('/api/v1/admin/settings');
|
final settings = await _repository.fetchSettings();
|
||||||
if (res.statusCode == 200 && res.data is Map) {
|
settings.forEach((svc, items) {
|
||||||
final Map<String, dynamic> data = Map<String, dynamic>.from(res.data);
|
_controllers.putIfAbsent(svc, () => {});
|
||||||
data.forEach((svc, items) {
|
for (final s in items) {
|
||||||
if (items is List) {
|
if (_controllers[svc]!.containsKey(s.key)) {
|
||||||
_controllers.putIfAbsent(svc, () => {});
|
_controllers[svc]![s.key]!.text = s.value;
|
||||||
for (var item in items) {
|
} else {
|
||||||
final key = item['key']?.toString();
|
_controllers[svc]![s.key] = TextEditingController(text: s.value);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Retain standard default in-memory values
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -179,9 +175,7 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
payload[k] = v.text;
|
payload[k] = v.text;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (widget.apiClient != null) {
|
await _repository.updateServiceSettings(_selectedServiceKey, payload);
|
||||||
await widget.apiClient!.put('/api/v1/admin/settings/$_selectedServiceKey', data: payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -192,7 +186,7 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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),
|
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -219,7 +213,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
|
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
|
||||||
@@ -228,7 +221,6 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Service Selection Ribbon
|
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -250,15 +242,16 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
svc.displayName,
|
svc.displayName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
color: isSelected ? Colors.white : AppTheme.textMuted,
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -269,148 +262,41 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
// Service Details & Config Panel
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
GlassContainer(
|
children: [
|
||||||
padding: const EdgeInsets.all(20),
|
Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Text(activeService.displayName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
Row(
|
const SizedBox(height: 2),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Text(activeService.description, style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
|
||||||
children: [
|
],
|
||||||
Row(
|
),
|
||||||
children: [
|
ElevatedButton.icon(
|
||||||
Container(
|
onPressed: _isSaving ? null : _saveSettings,
|
||||||
padding: const EdgeInsets.all(10),
|
icon: _isSaving
|
||||||
decoration: BoxDecoration(
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||||
color: activeService.accentColor.withValues(alpha: 0.15),
|
: const Icon(Icons.save_outlined, size: 16),
|
||||||
borderRadius: BorderRadius.circular(10),
|
label: Text(_isSaving ? 'Speichere...' : 'Speichern'),
|
||||||
border: Border.all(color: activeService.accentColor.withValues(alpha: 0.3)),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
child: Icon(activeService.icon, color: activeService.accentColor, size: 22),
|
foregroundColor: Colors.black,
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
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: 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()));
|
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 {}
|
abstract class AssetTechnicalEvent {}
|
||||||
|
|
||||||
class LoadAssetTechnical extends AssetTechnicalEvent {
|
class LoadAssetTechnical extends AssetTechnicalEvent {
|
||||||
final String isin;
|
final String isin;
|
||||||
final bool forceRefresh;
|
final bool forceRefresh;
|
||||||
final String? ticker;
|
final String? ticker;
|
||||||
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.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';
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
|
||||||
abstract class AssetTechnicalState {}
|
abstract class AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalInitial extends AssetTechnicalState {}
|
class AssetTechnicalInitial extends AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalLoading extends AssetTechnicalState {}
|
class AssetTechnicalLoading extends AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalLoaded extends AssetTechnicalState {
|
class AssetTechnicalLoaded extends AssetTechnicalState {
|
||||||
final TechnicalAnalysisModel? data;
|
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 {
|
class AssetTechnicalError extends AssetTechnicalState {
|
||||||
final String message;
|
final String message;
|
||||||
AssetTechnicalError(this.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 '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 {
|
class FundamentalDataModel extends Equatable {
|
||||||
final String isin;
|
final String isin;
|
||||||
@@ -16,10 +25,10 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final double currentPrice;
|
final double currentPrice;
|
||||||
final double dayChangeAbsolute;
|
final double dayChangeAbsolute;
|
||||||
final double dayChangePercent;
|
final double dayChangePercent;
|
||||||
final double fiftyTwoWeekHigh;
|
final double? fiftyTwoWeekHigh;
|
||||||
final double fiftyTwoWeekLow;
|
final double? fiftyTwoWeekLow;
|
||||||
final double marketCapitalization;
|
final double? marketCapitalization;
|
||||||
final double enterpriseValue;
|
final double? enterpriseValue;
|
||||||
|
|
||||||
final double? peRatioTrailing;
|
final double? peRatioTrailing;
|
||||||
final double? peRatioForward;
|
final double? peRatioForward;
|
||||||
@@ -85,10 +94,10 @@ class FundamentalDataModel extends Equatable {
|
|||||||
required this.currentPrice,
|
required this.currentPrice,
|
||||||
required this.dayChangeAbsolute,
|
required this.dayChangeAbsolute,
|
||||||
required this.dayChangePercent,
|
required this.dayChangePercent,
|
||||||
required this.fiftyTwoWeekHigh,
|
this.fiftyTwoWeekHigh,
|
||||||
required this.fiftyTwoWeekLow,
|
this.fiftyTwoWeekLow,
|
||||||
required this.marketCapitalization,
|
this.marketCapitalization,
|
||||||
required this.enterpriseValue,
|
this.enterpriseValue,
|
||||||
this.peRatioTrailing,
|
this.peRatioTrailing,
|
||||||
this.peRatioForward,
|
this.peRatioForward,
|
||||||
this.pegRatio,
|
this.pegRatio,
|
||||||
@@ -135,12 +144,6 @@ class FundamentalDataModel extends Equatable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
|
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) {
|
double? parseNullableDouble(dynamic val) {
|
||||||
if (val == null) return null;
|
if (val == null) return null;
|
||||||
if (val is num) return val.toDouble();
|
if (val is num) return val.toDouble();
|
||||||
@@ -171,8 +174,8 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
final tickerVal = extractTickerStr(fundMap?['ticker'] ?? json['ticker']).isNotEmpty
|
||||||
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
? extractTickerStr(fundMap?['ticker'] ?? json['ticker'])
|
||||||
: primaryTickerVal;
|
: primaryTickerVal;
|
||||||
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? json['name']?.toString() ?? tickerVal;
|
final companyNameVal = assetMap?['name']?.toString() ?? json['companyName']?.toString() ?? tickerVal;
|
||||||
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString() ?? json['description']?.toString();
|
final businessSummaryVal = assetMap?['description']?.toString() ?? json['businessSummary']?.toString();
|
||||||
|
|
||||||
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
final exchangeVal = extractExchangeStr(fundMap?['ticker']) ??
|
||||||
extractExchangeStr(assetMap?['primaryTicker']) ??
|
extractExchangeStr(assetMap?['primaryTicker']) ??
|
||||||
@@ -181,16 +184,12 @@ class FundamentalDataModel extends Equatable {
|
|||||||
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
final rawTickers = assetMap?['availableTickers'] ?? json['availableTickers'];
|
||||||
List<TickerModel> availableTickersList = [];
|
List<TickerModel> availableTickersList = [];
|
||||||
if (rawTickers is List) {
|
if (rawTickers is List) {
|
||||||
availableTickersList = rawTickers.map((t) {
|
availableTickersList = rawTickers
|
||||||
if (t is Map<String, dynamic>) {
|
.whereType<Map<String, dynamic>>()
|
||||||
return TickerModel.fromJson(t);
|
.map((t) => TickerModel.fromJson(t))
|
||||||
} else {
|
.toList();
|
||||||
return TickerModel(ticker: t.toString());
|
|
||||||
}
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revenue & Margins Derivation
|
|
||||||
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
final totalRev = parseNullableDouble(fundMap?['totalRevenue'] ?? json['totalRevenue']);
|
||||||
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
final grossProf = parseNullableDouble(fundMap?['grossProfit'] ?? json['grossProfit']);
|
||||||
double? grossMarginVal = parseNullableDouble(fundMap?['grossMargin'] ?? json['grossMargin']);
|
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']);
|
final evVal = parseNullableDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']);
|
||||||
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
double? evToRevVal = parseNullableDouble(fundMap?['evToRevenue'] ?? fundMap?['enterpriseValueToRevenue'] ?? json['evToRevenue']);
|
||||||
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
if (evToRevVal == null && evVal != null && totalRev != null && totalRev > 0) {
|
||||||
evToRevVal = evVal / totalRev;
|
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(
|
return FundamentalDataModel(
|
||||||
isin: isinVal,
|
isin: isinVal,
|
||||||
primaryTicker: primaryTickerVal,
|
primaryTicker: primaryTickerVal,
|
||||||
ticker: tickerVal,
|
ticker: tickerVal,
|
||||||
companyName: companyNameVal,
|
companyName: companyNameVal,
|
||||||
exchange: exchangeVal,
|
exchange: exchangeVal,
|
||||||
tradingCurrency: json['tradingCurrency']?.toString(),
|
tradingCurrency: fundMap?['currency']?.toString() ?? json['tradingCurrency']?.toString(),
|
||||||
businessSummary: businessSummaryVal,
|
businessSummary: businessSummaryVal,
|
||||||
sector: json['sector']?.toString(),
|
sector: assetMap?['sector']?.toString() ?? json['sector']?.toString(),
|
||||||
industry: json['industry']?.toString(),
|
industry: assetMap?['industry']?.toString() ?? json['industry']?.toString(),
|
||||||
country: json['country']?.toString(),
|
country: assetMap?['country']?.toString() ?? json['country']?.toString(),
|
||||||
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
|
employees: (assetMap?['employees'] ?? json['employees']) is int
|
||||||
currentPrice: parseDouble(json['currentPrice']),
|
? (assetMap?['employees'] ?? json['employees']) as int
|
||||||
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
|
: int.tryParse((assetMap?['employees'] ?? json['employees'])?.toString() ?? ''),
|
||||||
dayChangePercent: parseDouble(json['dayChangePercent']),
|
currentPrice: parseNullableDouble(fundMap?['currentPrice'] ?? json['currentPrice']) ?? 0.0,
|
||||||
fiftyTwoWeekHigh: parseDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
dayChangeAbsolute: parseNullableDouble(fundMap?['dayChangeAbsolute'] ?? json['dayChangeAbsolute']) ?? 0.0,
|
||||||
fiftyTwoWeekLow: parseDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
dayChangePercent: parseNullableDouble(fundMap?['dayChangePercent'] ?? json['dayChangePercent']) ?? 0.0,
|
||||||
marketCapitalization: parseDouble(fundMap?['marketCap'] ?? json['marketCapitalization'] ?? json['marketCap']),
|
fiftyTwoWeekHigh: parseNullableDouble(fundMap?['fiftyTwoWeekHigh'] ?? json['fiftyTwoWeekHigh']),
|
||||||
enterpriseValue: parseDouble(fundMap?['enterpriseValue'] ?? json['enterpriseValue']),
|
fiftyTwoWeekLow: parseNullableDouble(fundMap?['fiftyTwoWeekLow'] ?? json['fiftyTwoWeekLow']),
|
||||||
peRatioTrailing: parseNullableDouble(fundMap?['trailingPe'] ?? json['peRatioTrailing'] ?? json['peRatio']),
|
marketCapitalization: parseNullableDouble(fundMap?['marketCap'] ?? fundMap?['marketCapitalization'] ?? json['marketCapitalization']),
|
||||||
peRatioForward: parseNullableDouble(fundMap?['forwardPe'] ?? json['peRatioForward']),
|
enterpriseValue: evVal,
|
||||||
|
peRatioTrailing: parseNullableDouble(fundMap?['trailingPE'] ?? fundMap?['peRatioTrailing'] ?? json['peRatioTrailing']),
|
||||||
|
peRatioForward: parseNullableDouble(fundMap?['forwardPE'] ?? fundMap?['peRatioForward'] ?? json['peRatioForward']),
|
||||||
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
|
pegRatio: parseNullableDouble(fundMap?['pegRatio'] ?? json['pegRatio']),
|
||||||
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? json['pbRatio']),
|
pbRatio: parseNullableDouble(fundMap?['priceToBook'] ?? fundMap?['pbRatio'] ?? json['pbRatio']),
|
||||||
psRatio: parseNullableDouble(fundMap?['priceToSales'] ?? json['psRatio']),
|
psRatio: parseNullableDouble(fundMap?['priceToSalesTrailing12Months'] ?? fundMap?['psRatio'] ?? json['psRatio']),
|
||||||
evToEbitda: parseNullableDouble(fundMap?['evToEbitda'] ?? json['evToEbitda']),
|
evToEbitda: parseNullableDouble(fundMap?['enterpriseToEbitda'] ?? fundMap?['evToEbitda'] ?? json['evToEbitda']),
|
||||||
evToRevenue: evToRevVal,
|
evToRevenue: evToRevVal,
|
||||||
totalRevenue: totalRev,
|
totalRevenue: totalRev,
|
||||||
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
|
revenueGrowthYoY: parseNullableDouble(fundMap?['revenueGrowth'] ?? fundMap?['revenueGrowthYoY'] ?? json['revenueGrowthYoY']),
|
||||||
grossProfit: grossProf,
|
grossProfit: grossProf,
|
||||||
ebitda: parseNullableDouble(fundMap?['ebitda'] ?? json['ebitda']),
|
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']),
|
totalCash: parseNullableDouble(fundMap?['totalCash'] ?? json['totalCash']),
|
||||||
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
totalDebt: parseNullableDouble(fundMap?['totalDebt'] ?? json['totalDebt']),
|
||||||
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
|
operatingCashFlow: parseNullableDouble(fundMap?['operatingCashflow'] ?? fundMap?['operatingCashFlow'] ?? json['operatingCashFlow']),
|
||||||
freeCashFlow: parseNullableDouble(fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
|
freeCashFlow: parseNullableDouble(fundMap?['freeCashflow'] ?? fundMap?['freeCashFlow'] ?? json['freeCashFlow']),
|
||||||
grossMargin: grossMarginVal,
|
grossMargin: grossMarginVal,
|
||||||
operatingMargin: parseNullableDouble(fundMap?['operatingMargin'] ?? fundMap?['operatingIncome'] ?? json['operatingMargin']),
|
operatingMargin: parseNullableDouble(fundMap?['operatingMargins'] ?? fundMap?['operatingMargin'] ?? json['operatingMargin']),
|
||||||
netProfitMargin: parseNullableDouble(fundMap?['netProfitMargin'] ?? fundMap?['netIncome'] ?? json['netProfitMargin']),
|
netProfitMargin: parseNullableDouble(fundMap?['profitMargins'] ?? fundMap?['netProfitMargin'] ?? json['netProfitMargin']),
|
||||||
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
returnOnEquity: parseNullableDouble(fundMap?['returnOnEquity'] ?? json['returnOnEquity']),
|
||||||
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
returnOnAssets: parseNullableDouble(fundMap?['returnOnAssets'] ?? json['returnOnAssets']),
|
||||||
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
returnOnInvestedCapital: parseNullableDouble(fundMap?['returnOnInvestedCapital'] ?? json['returnOnInvestedCapital']),
|
||||||
@@ -299,424 +254,51 @@ class FundamentalDataModel extends Equatable {
|
|||||||
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
currentRatio: parseNullableDouble(fundMap?['currentRatio'] ?? json['currentRatio']),
|
||||||
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
quickRatio: parseNullableDouble(fundMap?['quickRatio'] ?? json['quickRatio']),
|
||||||
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
|
interestCoverage: parseNullableDouble(fundMap?['interestCoverage'] ?? json['interestCoverage']),
|
||||||
dividendYield: parseNullableDouble(fundMap?['forwardDividendYield'] ?? json['dividendYield']),
|
dividendYield: parseNullableDouble(fundMap?['dividendYield'] ?? json['dividendYield']),
|
||||||
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
|
payoutRatio: parseNullableDouble(fundMap?['payoutRatio'] ?? json['payoutRatio']),
|
||||||
exDividendDate: exDividendDateVal,
|
exDividendDate: fundMap?['exDividendDate']?.toString() ?? json['exDividendDate']?.toString(),
|
||||||
nextEarningsDate: nextEarningsDateVal,
|
nextEarningsDate: fundMap?['nextEarningsDate']?.toString() ?? json['nextEarningsDate']?.toString(),
|
||||||
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
percentHeldByInstitutions: parseNullableDouble(fundMap?['percentHeldByInstitutions'] ?? json['percentHeldByInstitutions']),
|
||||||
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
percentHeldByInsiders: parseNullableDouble(fundMap?['percentHeldByInsiders'] ?? json['percentHeldByInsiders']),
|
||||||
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
shortRatio: parseNullableDouble(fundMap?['shortRatio'] ?? json['shortRatio']),
|
||||||
shortPercentOfFloat: parseNullableDouble(fundMap?['shortPercentOfFloat'] ?? json['shortPercentOfFloat']),
|
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']),
|
priceTargetLow: parseNullableDouble(fundMap?['priceTargetLow'] ?? json['priceTargetLow']),
|
||||||
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
priceTargetHigh: parseNullableDouble(fundMap?['priceTargetHigh'] ?? json['priceTargetHigh']),
|
||||||
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
priceTargetMedian: parseNullableDouble(fundMap?['priceTargetMedian'] ?? json['priceTargetMedian']),
|
||||||
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
priceTargetMean: parseNullableDouble(fundMap?['priceTargetMean'] ?? json['priceTargetMean']),
|
||||||
executives: (json['executives'] as List?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
financialStatements: (json['financialStatements'] as List?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
estimates: (json['estimates'] as List?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
availableTickers: availableTickersList,
|
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
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
isin,
|
isin, primaryTicker, ticker, companyName, exchange, tradingCurrency,
|
||||||
primaryTicker,
|
businessSummary, sector, industry, country, employees, currentPrice,
|
||||||
ticker,
|
dayChangeAbsolute, dayChangePercent, fiftyTwoWeekHigh, fiftyTwoWeekLow,
|
||||||
companyName,
|
marketCapitalization, enterpriseValue, peRatioTrailing, peRatioForward,
|
||||||
exchange,
|
pegRatio, pbRatio, psRatio, evToEbitda, evToRevenue, grossMargin,
|
||||||
tradingCurrency,
|
operatingMargin, netProfitMargin, returnOnEquity, returnOnAssets,
|
||||||
businessSummary,
|
returnOnInvestedCapital, debtToEquity, currentRatio, quickRatio,
|
||||||
sector,
|
dividendYield, payoutRatio, exDividendDate, nextEarningsDate,
|
||||||
industry,
|
percentHeldByInstitutions, percentHeldByInsiders, shortRatio,
|
||||||
country,
|
shortPercentOfFloat, consensusRating, priceTargetLow, priceTargetHigh,
|
||||||
employees,
|
priceTargetMedian, priceTargetMean, executives, financialStatements,
|
||||||
currentPrice,
|
estimates, availableTickers,
|
||||||
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 {
|
class TechnicalAnalysisModel extends Equatable {
|
||||||
final String symbol;
|
final String symbol;
|
||||||
final String currency;
|
final String currency;
|
||||||
|
final double? currentPrice;
|
||||||
final String trend;
|
final String trend;
|
||||||
final String rsi;
|
final String rsi;
|
||||||
final String macd;
|
final String macd;
|
||||||
final String overallSignal;
|
final String overallSignal;
|
||||||
final String sma50;
|
final String sma50;
|
||||||
final String sma200;
|
final String sma200;
|
||||||
final double vix;
|
final double? vix;
|
||||||
final String sp500Trend;
|
final String? sp500Trend;
|
||||||
final double dxy;
|
final double? dxy;
|
||||||
final double? stopLossAtr;
|
final double? stopLossAtr;
|
||||||
final List<CandleModel> candles;
|
final List<CandleModel> candles;
|
||||||
final List<IndicatorModel> indicators;
|
final List<IndicatorModel> indicators;
|
||||||
@@ -216,15 +217,16 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
const TechnicalAnalysisModel({
|
const TechnicalAnalysisModel({
|
||||||
required this.symbol,
|
required this.symbol,
|
||||||
this.currency = 'EUR',
|
this.currency = 'EUR',
|
||||||
|
this.currentPrice,
|
||||||
required this.trend,
|
required this.trend,
|
||||||
required this.rsi,
|
required this.rsi,
|
||||||
required this.macd,
|
required this.macd,
|
||||||
required this.overallSignal,
|
required this.overallSignal,
|
||||||
required this.sma50,
|
required this.sma50,
|
||||||
required this.sma200,
|
required this.sma200,
|
||||||
this.vix = 16.5,
|
this.vix,
|
||||||
this.sp500Trend = 'Bullish',
|
this.sp500Trend,
|
||||||
this.dxy = 104.2,
|
this.dxy,
|
||||||
this.stopLossAtr,
|
this.stopLossAtr,
|
||||||
this.candles = const [],
|
this.candles = const [],
|
||||||
this.indicators = const [],
|
this.indicators = const [],
|
||||||
@@ -245,7 +247,6 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
|
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
|
||||||
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
|
||||||
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
||||||
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
@@ -259,17 +260,18 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return TechnicalAnalysisModel(
|
return TechnicalAnalysisModel(
|
||||||
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
currency: json['currency']?.toString() ?? 'EUR',
|
currency: json['currency']?.toString() ?? 'EUR',
|
||||||
|
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
|
||||||
trend: parsedTrend,
|
trend: parsedTrend,
|
||||||
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
||||||
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
||||||
overallSignal: parsedSignal,
|
overallSignal: parsedSignal,
|
||||||
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
||||||
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
||||||
vix: (regime?['vixValue'] as num?)?.toDouble() ?? 16.5,
|
vix: (regime?['vixValue'] as num?)?.toDouble(),
|
||||||
sp500Trend: regime?['marketTrend']?.toString() ?? 'Bullish',
|
sp500Trend: regime?['marketTrend']?.toString(),
|
||||||
dxy: (regime?['dxyValue'] as num?)?.toDouble() ?? 104.2,
|
dxy: (regime?['dxyValue'] as num?)?.toDouble(),
|
||||||
stopLossAtr: lastInd?.recommendedStopLoss,
|
stopLossAtr: lastInd?.recommendedStopLoss,
|
||||||
candles: candlesList,
|
candles: candlesList,
|
||||||
indicators: indicatorsList,
|
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/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/fundamental_data_model.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_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_request_dto.dart';
|
||||||
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_response_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_model.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.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 {
|
class AssetRepository {
|
||||||
final ApiClient apiClient;
|
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 {
|
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 {
|
try {
|
||||||
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
String url = '/api/v1/assets/$isin/fundamentals?forceRefresh=$forceRefresh';
|
||||||
if (ticker != null && ticker.isNotEmpty) {
|
if (ticker != null && ticker.isNotEmpty) {
|
||||||
url += '&ticker=$ticker';
|
url += '&ticker=$ticker';
|
||||||
}
|
}
|
||||||
final res = await apiClient.get(url);
|
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);
|
return FundamentalDataModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
print('Error fetching fundamentals for $isin: $e');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
|
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 {
|
try {
|
||||||
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
String url = '/api/v1/assets/$isin/technicals?forceRefresh=$forceRefresh';
|
||||||
if (ticker != null && ticker.isNotEmpty) {
|
if (ticker != null && ticker.isNotEmpty) {
|
||||||
url += '&ticker=$ticker';
|
url += '&ticker=$ticker';
|
||||||
}
|
}
|
||||||
final res = await apiClient.get(url);
|
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);
|
return TechnicalAnalysisModel.fromJson(res.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
print('Error fetching TA for $isin: $e');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
|
||||||
try {
|
return _tradeRepository.fetchTrades(isin: isin, status: status);
|
||||||
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 [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
Future<ManualAnalysisResponseDto?> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
|
||||||
try {
|
final body = payload != null ? payload.toJson() : {'isin': isin};
|
||||||
final body = payload != null ? payload.toJson() : {'isin': isin};
|
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
||||||
final res = await apiClient.post('/api/v1/analyze/manual', data: body);
|
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
||||||
if (res.statusCode == 200 && res.data != null && res.data is Map<String, dynamic>) {
|
return ManualAnalysisResponseDto.fromJson(res.data);
|
||||||
return ManualAnalysisResponseDto.fromJson(res.data);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error triggering manual analysis for $isin: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> rejectTrade(String tradeId) async {
|
Future<void> rejectTrade(String tradeId) async => _tradeRepository.rejectTrade(tradeId);
|
||||||
try {
|
|
||||||
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
|
|
||||||
} catch (e) {
|
|
||||||
print('Error rejecting trade $tradeId: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
|
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async => _tradeRepository.acceptTrade(tradeAcceptanceDto);
|
||||||
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> closeTrade(String tradeId, double exitPrice) async {
|
Future<void> closeTrade(String tradeId, double exitPrice) async =>
|
||||||
try {
|
_tradeRepository.closeTrade(tradeId, dto: CloseTradeRequestDto(userExitPrice: exitPrice));
|
||||||
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error closing trade $tradeId: $e');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
static void show(BuildContext context, String key) {
|
||||||
final info = data[key];
|
final info = data[key];
|
||||||
if (info == null) return;
|
if (info == null) return;
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ class PatternExplanations {
|
|||||||
return colors[patternType.hashCode.abs() % colors.length];
|
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) {
|
static void showPatternDetails(BuildContext context, String rawPatternType) {
|
||||||
final key = dictionary.keys.firstWhere(
|
final key = dictionary.keys.firstWhere(
|
||||||
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
|
(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 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../bloc/header/asset_header_bloc.dart';
|
import '../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../bloc/header/asset_header_event.dart';
|
|
||||||
import '../bloc/technical/asset_technical_bloc.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_bloc.dart';
|
||||||
import '../bloc/trades/asset_trades_event.dart';
|
import '../bloc/trades/asset_trades_event.dart';
|
||||||
import '../repositories/asset_repository.dart';
|
import '../repositories/asset_repository.dart';
|
||||||
@@ -32,14 +32,12 @@ class AssetDetailScreen extends StatelessWidget {
|
|||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetHeaderBloc(repository: repository)
|
create: (context) => AssetFundamentalsBloc(repository: repository)
|
||||||
..add(LoadAssetHeader(isin, ticker: symbol)),
|
..add(LoadAssetFundamentals(isin, ticker: symbol)),
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetFundamentalsBloc(repository: repository),
|
create: (context) => AssetTechnicalBloc(repository: repository)
|
||||||
),
|
..add(LoadAssetTechnical(isin, ticker: symbol)),
|
||||||
BlocProvider(
|
|
||||||
create: (context) => AssetTechnicalBloc(repository: repository),
|
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => AssetTradesBloc(repository: repository)
|
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 '../../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.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_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
@@ -21,8 +18,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? selectedTicker;
|
final String? selectedTicker;
|
||||||
|
|
||||||
const AssetPageDesktopLayout(
|
const AssetPageDesktopLayout({
|
||||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.selectedTicker,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
|
||||||
@@ -31,12 +32,12 @@ class AssetPageDesktopLayout extends StatefulWidget {
|
|||||||
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
String? _selectedExchange;
|
|
||||||
String? _selectedTicker;
|
String? _selectedTicker;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_selectedTicker = widget.selectedTicker;
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +49,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
|
|
||||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedExchange = newExchange;
|
|
||||||
_selectedTicker = newTicker;
|
_selectedTicker = newTicker;
|
||||||
});
|
});
|
||||||
context.read<AssetHeaderBloc>().add(
|
|
||||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
ticker: newTicker, forceRefresh: false));
|
ticker: newTicker, forceRefresh: false));
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
@@ -65,10 +63,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceRefresh() {
|
void _handleForceRefresh() {
|
||||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
forceRefresh: true,
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
exchange: _selectedExchange,
|
|
||||||
ticker: _selectedTicker));
|
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
ticker: _selectedTicker, forceRefresh: true));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||||
@@ -78,119 +74,100 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
|
return SingleChildScrollView(
|
||||||
listener: (context, state) {
|
physics: const BouncingScrollPhysics(),
|
||||||
if (state is AssetHeaderLoaded && state.data != null) {
|
child: Column(
|
||||||
if (_selectedTicker == null) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
setState(() {
|
children: [
|
||||||
_selectedTicker = widget.selectedTicker;
|
// 1. Hero Header
|
||||||
});
|
AssetHeroHeader(
|
||||||
}
|
isin: widget.isin,
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
name: widget.name ?? widget.isin,
|
||||||
widget.isin,
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
ticker: _selectedTicker,
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
forceRefresh: false));
|
onForceRefresh: _handleForceRefresh,
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
),
|
||||||
widget.isin,
|
|
||||||
ticker: _selectedTicker,
|
// 2. Full-Width Interactive Chart Section
|
||||||
forceRefresh: false));
|
Container(
|
||||||
}
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
child: SingleChildScrollView(
|
color: theme.cardSurface,
|
||||||
physics: const BouncingScrollPhysics(),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Column(
|
border: Border.all(color: theme.glassBorder),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: TechnicalTab(
|
||||||
// 1. Hero Header
|
|
||||||
AssetHeroHeader(
|
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
name: widget.name ?? widget.isin,
|
symbol: _selectedTicker,
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
showChartOnly: true,
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
chartHeight: 460,
|
||||||
onForceRefresh: _handleForceRefresh,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 2. Full-Width Interactive Chart Section
|
const SizedBox(height: 8),
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
// 3. Detailed Sections & Fundamentals under the Chart
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: theme.cardSurface,
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
borderRadius: BorderRadius.circular(16),
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.glassBorder),
|
color: theme.cardSurface,
|
||||||
),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: TechnicalTab(
|
border: Border.all(color: theme.glassBorder),
|
||||||
isin: widget.isin,
|
|
||||||
symbol: _selectedTicker,
|
|
||||||
showChartOnly: true,
|
|
||||||
chartHeight: 460,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Column(
|
||||||
const SizedBox(height: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
// 3. Detailed Sections & Fundamentals under the Chart
|
TabBar(
|
||||||
Container(
|
controller: _tabController,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
labelColor: theme.primaryColor,
|
||||||
decoration: BoxDecoration(
|
unselectedLabelColor: theme.textMuted,
|
||||||
color: theme.cardSurface,
|
indicatorColor: theme.primaryColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
dividerColor: theme.glassBorder,
|
||||||
border: Border.all(color: theme.glassBorder),
|
labelStyle: const TextStyle(
|
||||||
),
|
fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
child: Column(
|
tabs: const [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Tab(
|
||||||
children: [
|
icon: Icon(Icons.analytics_outlined, size: 18),
|
||||||
TabBar(
|
text: 'FUNDAMENTALS & ÜBERSICHT'),
|
||||||
controller: _tabController,
|
Tab(
|
||||||
labelColor: theme.primaryColor,
|
icon: Icon(Icons.architecture_outlined, size: 18),
|
||||||
unselectedLabelColor: theme.textMuted,
|
text: 'MUSTER & SIGNALE'),
|
||||||
indicatorColor: theme.primaryColor,
|
Tab(
|
||||||
dividerColor: theme.glassBorder,
|
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
|
||||||
labelStyle: const TextStyle(
|
text: 'TRADES'),
|
||||||
fontWeight: FontWeight.bold, fontSize: 13),
|
],
|
||||||
tabs: const [
|
),
|
||||||
Tab(
|
AnimatedBuilder(
|
||||||
icon: Icon(Icons.analytics_outlined, size: 18),
|
animation: _tabController,
|
||||||
text: 'FUNDAMENTALS & ÜBERSICHT'),
|
builder: (context, _) {
|
||||||
Tab(
|
switch (_tabController.index) {
|
||||||
icon: Icon(Icons.architecture_outlined, size: 18),
|
case 0:
|
||||||
text: 'MUSTER & SIGNALE'),
|
return FundamentalsTab(
|
||||||
Tab(
|
isin: widget.isin,
|
||||||
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
|
symbol: _selectedTicker,
|
||||||
text: 'TRADES'),
|
isEmbedded: true,
|
||||||
],
|
);
|
||||||
),
|
case 1:
|
||||||
AnimatedBuilder(
|
return TechnicalTab(
|
||||||
animation: _tabController,
|
isin: widget.isin,
|
||||||
builder: (context, _) {
|
symbol: _selectedTicker,
|
||||||
switch (_tabController.index) {
|
showDetailsOnly: true,
|
||||||
case 0:
|
);
|
||||||
return FundamentalsTab(
|
case 2:
|
||||||
isin: widget.isin,
|
return SizedBox(
|
||||||
symbol: _selectedTicker,
|
height: 600,
|
||||||
isEmbedded: true,
|
child: TradesTab(symbol: widget.isin),
|
||||||
);
|
);
|
||||||
case 1:
|
default:
|
||||||
return TechnicalTab(
|
return const SizedBox.shrink();
|
||||||
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 '../../../favorites/cubit/favorites_cubit.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.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_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
@@ -21,8 +18,12 @@ class AssetPageMobileLayout extends StatefulWidget {
|
|||||||
final String? name;
|
final String? name;
|
||||||
final String? selectedTicker;
|
final String? selectedTicker;
|
||||||
|
|
||||||
const AssetPageMobileLayout(
|
const AssetPageMobileLayout({
|
||||||
{super.key, required this.isin, this.selectedTicker, this.name});
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.selectedTicker,
|
||||||
|
this.name,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
|
||||||
@@ -36,7 +37,7 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
//_selectedTicker = widget.selectedTicker;
|
_selectedTicker = widget.selectedTicker;
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +49,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
|
|
||||||
void _handleExchangeChanged(String newExchange, String newTicker) {
|
void _handleExchangeChanged(String newExchange, String newTicker) {
|
||||||
setState(() {
|
setState(() {
|
||||||
//_selectedExchange = newExchange;
|
|
||||||
_selectedTicker = newTicker;
|
_selectedTicker = newTicker;
|
||||||
});
|
});
|
||||||
context.read<AssetHeaderBloc>().add(
|
|
||||||
LoadAssetHeader(widget.isin, exchange: newExchange, ticker: newTicker));
|
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
ticker: newTicker, forceRefresh: false));
|
ticker: newTicker, forceRefresh: false));
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
@@ -65,10 +63,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForceRefresh() {
|
void _handleForceRefresh() {
|
||||||
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.isin,
|
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin,
|
||||||
forceRefresh: true, ticker: _selectedTicker));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
// 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<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||||
ticker: _selectedTicker, forceRefresh: true));
|
ticker: _selectedTicker, forceRefresh: true));
|
||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||||
@@ -78,113 +74,102 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
|
return SingleChildScrollView(
|
||||||
listener: (context, state) {
|
physics: const BouncingScrollPhysics(),
|
||||||
if (state is AssetHeaderLoaded && state.data != null) {
|
child: Column(
|
||||||
if (_selectedTicker == null) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
setState(() {
|
children: [
|
||||||
_selectedTicker = widget.selectedTicker;
|
// 1. Hero Header
|
||||||
});
|
AssetHeroHeader(
|
||||||
}
|
isin: widget.isin,
|
||||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
name: widget.name ?? widget.isin,
|
||||||
widget.isin,
|
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||||
ticker: _selectedTicker,
|
onExchangeChanged: _handleExchangeChanged,
|
||||||
forceRefresh: false));
|
onForceRefresh: _handleForceRefresh,
|
||||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(
|
),
|
||||||
widget.isin,
|
|
||||||
ticker: _selectedTicker,
|
// 2. Interactive Chart
|
||||||
forceRefresh: false));
|
Container(
|
||||||
}
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
child: SingleChildScrollView(
|
color: theme.cardSurface,
|
||||||
physics: const BouncingScrollPhysics(),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Column(
|
border: Border.all(color: theme.glassBorder),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: TechnicalTab(
|
||||||
// 1. Hero Header
|
|
||||||
AssetHeroHeader(
|
|
||||||
isin: widget.isin,
|
isin: widget.isin,
|
||||||
name: widget.name ?? widget.isin,
|
symbol: _selectedTicker,
|
||||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
showChartOnly: true,
|
||||||
onExchangeChanged: _handleExchangeChanged,
|
chartHeight: 320,
|
||||||
onForceRefresh: _handleForceRefresh,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 2. Full-Width Interactive Chart Section
|
const SizedBox(height: 8),
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
// 3. Tabbed Detailed Analysis
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: theme.cardSurface,
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
borderRadius: BorderRadius.circular(16),
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.glassBorder),
|
color: theme.cardSurface,
|
||||||
),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: TechnicalTab(
|
border: Border.all(color: theme.glassBorder),
|
||||||
isin: widget.isin,
|
|
||||||
symbol: _selectedTicker,
|
|
||||||
showChartOnly: true,
|
|
||||||
chartHeight: 330,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Column(
|
||||||
const SizedBox(height: 6),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
// 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades)
|
TabBar(
|
||||||
Container(
|
controller: _tabController,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
labelColor: theme.primaryColor,
|
||||||
decoration: BoxDecoration(
|
unselectedLabelColor: theme.textMuted,
|
||||||
color: theme.cardSurface,
|
indicatorColor: theme.primaryColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
dividerColor: theme.glassBorder,
|
||||||
border: Border.all(color: theme.glassBorder),
|
isScrollable: true,
|
||||||
),
|
tabAlignment: TabAlignment.start,
|
||||||
child: Column(
|
labelStyle: const TextStyle(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
children: [
|
tabs: const [
|
||||||
TabBar(
|
Tab(
|
||||||
controller: _tabController,
|
icon: Icon(Icons.analytics_outlined, size: 16),
|
||||||
labelColor: theme.primaryColor,
|
text: 'FUNDAMENTALS'),
|
||||||
unselectedLabelColor: theme.textMuted,
|
Tab(
|
||||||
indicatorColor: theme.primaryColor,
|
icon: Icon(Icons.architecture_outlined, size: 16),
|
||||||
dividerColor: theme.glassBorder,
|
text: 'MUSTER & SIGNALE'),
|
||||||
labelStyle: const TextStyle(
|
Tab(
|
||||||
fontWeight: FontWeight.bold, fontSize: 12),
|
icon: Icon(Icons.candlestick_chart_outlined, size: 16),
|
||||||
tabs: const [
|
text: 'TRADES'),
|
||||||
Tab(text: 'FUNDAMENTALS'),
|
],
|
||||||
Tab(text: 'MUSTER & SIGNALE'),
|
),
|
||||||
Tab(text: 'TRADES'),
|
AnimatedBuilder(
|
||||||
],
|
animation: _tabController,
|
||||||
),
|
builder: (context, _) {
|
||||||
AnimatedBuilder(
|
switch (_tabController.index) {
|
||||||
animation: _tabController,
|
case 0:
|
||||||
builder: (context, _) {
|
return FundamentalsTab(
|
||||||
switch (_tabController.index) {
|
isin: widget.isin,
|
||||||
case 0:
|
symbol: _selectedTicker,
|
||||||
return FundamentalsTab(
|
isEmbedded: true,
|
||||||
isin: widget.isin,
|
);
|
||||||
symbol: _selectedTicker,
|
case 1:
|
||||||
isEmbedded: true,
|
return TechnicalTab(
|
||||||
);
|
isin: widget.isin,
|
||||||
case 1:
|
symbol: _selectedTicker,
|
||||||
return TechnicalTab(
|
showDetailsOnly: true,
|
||||||
isin: widget.isin,
|
);
|
||||||
symbol: _selectedTicker,
|
case 2:
|
||||||
showDetailsOnly: true,
|
return SizedBox(
|
||||||
);
|
height: 500,
|
||||||
case 2:
|
child: TradesTab(symbol: widget.isin),
|
||||||
return SizedBox(
|
);
|
||||||
height: 500,
|
default:
|
||||||
child: TradesTab(symbol: widget.isin),
|
return const SizedBox.shrink();
|
||||||
);
|
}
|
||||||
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/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.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_bloc.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||||
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../models/fundamental_data_model.dart';
|
import '../../widgets/fundamentals/analyst_price_target_card.dart';
|
||||||
import '../../utils/metric_explanations.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 isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isEmbedded;
|
final bool isEmbedded;
|
||||||
@@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget {
|
|||||||
required this.isin,
|
required this.isin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
String _getCurrencySymbol(String? ticker) {
|
||||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
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')) {
|
||||||
class _FundamentalsTabState extends State<FundamentalsTab> {
|
return '€';
|
||||||
String _sym = '\$';
|
}
|
||||||
String _curCode = 'USD';
|
if (t.endsWith('.L')) return '£';
|
||||||
|
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
|
||||||
@override
|
return '\$';
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
String _getCurrencyCode(String? ticker) {
|
||||||
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
|
if (ticker == null || ticker.isEmpty) return 'EUR';
|
||||||
super.didUpdateWidget(oldWidget);
|
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
|
@override
|
||||||
@@ -60,7 +64,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
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),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
),
|
),
|
||||||
@@ -70,89 +74,68 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetFundamentalsLoaded) {
|
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||||
final data = state.data;
|
final data = state.data!;
|
||||||
if (data != null) {
|
final sym = _getCurrencySymbol(data.ticker);
|
||||||
_sym = _getCurrencySymbol(data.ticker);
|
final curCode = _getCurrencyCode(data.ticker);
|
||||||
_curCode = _getCurrencyCode(data.ticker);
|
|
||||||
}
|
|
||||||
if (data == null) {
|
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 1. Analyst Forecasts & Price Targets Header Card
|
// 1. Analyst Forecasts & Price Targets Header Card
|
||||||
_buildPriceTargetCard(data),
|
AnalystPriceTargetCard(data: data, currencySymbol: sym),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
||||||
_buildCategoryPanels(data),
|
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 3. Company Description & Detailed Executive Board
|
// 3. Company Description & Detailed Executive Board
|
||||||
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildProfileSection(data),
|
CompanyProfileSection(data: data, currencySymbol: sym),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildEmptyState();
|
return _buildEmptyState(context);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPriceTargetCard(FundamentalDataModel data) {
|
Widget _buildSectionHeader(String title, IconData icon) {
|
||||||
final rating = data.consensusRating ?? 'N/A';
|
return Row(
|
||||||
final targetMean = data.priceTargetMean;
|
children: [
|
||||||
final targetLow = data.priceTargetLow;
|
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
|
||||||
final targetHigh = data.priceTargetHigh;
|
const SizedBox(width: 8),
|
||||||
|
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
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) {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
return Column(
|
return Center(
|
||||||
children: [
|
child: GlassContainer(
|
||||||
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
padding: const EdgeInsets.all(24),
|
||||||
const SizedBox(height: 4),
|
child: Column(
|
||||||
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
|
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(
|
return SingleChildScrollView(
|
||||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Price Target Card Shimmer
|
|
||||||
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 3 Category Panels Shimmer
|
|
||||||
if (isDesktop)
|
if (isDesktop)
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -234,9 +214,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
|||||||
panelShimmer(),
|
panelShimmer(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// Profile Section Shimmer
|
|
||||||
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
|
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:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.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_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/technical/asset_technical_state.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/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 isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isDesktopLeftPanel;
|
final bool isDesktopLeftPanel;
|
||||||
@@ -30,31 +31,6 @@ class TechnicalTab extends StatefulWidget {
|
|||||||
required this.isin,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
@@ -73,12 +49,14 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
|
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Fehler beim Laden der Technischen Analyse: ${state.message}',
|
'Fehler beim Laden der Technischen Analyse: ${state.message}',
|
||||||
style: const TextStyle(color: Colors.white70)),
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
||||||
LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
),
|
),
|
||||||
@@ -88,221 +66,91 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetTechnicalLoaded) {
|
if (state is AssetTechnicalLoaded && state.data != null) {
|
||||||
final data = state.data;
|
final data = state.data!;
|
||||||
List<CandleModel> candles = [];
|
final candles = data.candles;
|
||||||
List<ChartPatternModel> patterns = [];
|
final patterns = data.patterns;
|
||||||
List<StrategySignalModel> signals = [];
|
final signals = data.signals;
|
||||||
List<IndicatorModel> indicators = [];
|
final indicators = data.indicators;
|
||||||
|
|
||||||
if (data != null) {
|
final activePatterns = <ChartPatternModel>[];
|
||||||
candles = data.candles
|
for (int i = 0; i < patterns.length; i++) {
|
||||||
.map((c) => CandleModel(
|
if (!state.disabledPatternIndices.contains(i)) {
|
||||||
time: c.timestamp,
|
activePatterns.add(patterns[i]);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter patterns according to individual checkbox states
|
final chartWidget = CandlestickChart(
|
||||||
final activePatterns = [
|
candles: candles,
|
||||||
for (int i = 0; i < patterns.length; i++)
|
indicators: indicators,
|
||||||
if (!_disabledPatternIndices.contains(i)) patterns[i]
|
patterns: activePatterns,
|
||||||
];
|
signals: signals,
|
||||||
|
showSma50: state.showSma50,
|
||||||
final chartRibbon = GlassContainer(
|
showSma200: state.showSma200,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
showEma: state.showEma,
|
||||||
child: SingleChildScrollView(
|
showPatterns: state.showPatterns,
|
||||||
scrollDirection: Axis.horizontal,
|
showSignals: state.showSignals,
|
||||||
child: Row(
|
showSupertrend: state.showSupertrend,
|
||||||
children: [
|
height: chartHeight,
|
||||||
_buildIndicatorChip(
|
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
|
||||||
'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 = SizedBox(
|
final chartRibbon = IndicatorRibbonBar(
|
||||||
height: widget.chartHeight,
|
showSma50: state.showSma50,
|
||||||
width: double.infinity,
|
showSma200: state.showSma200,
|
||||||
child: CandlestickChart(
|
showEma: state.showEma,
|
||||||
candles: candles,
|
showSupertrend: state.showSupertrend,
|
||||||
patterns: activePatterns,
|
showPatterns: state.showPatterns,
|
||||||
signals: signals,
|
showSignals: state.showSignals,
|
||||||
indicators: indicators,
|
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
|
||||||
showPatterns: _showPatterns,
|
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
|
||||||
showEma: _showEma,
|
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
|
||||||
showSma50: _showSma50,
|
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
|
||||||
showSma200: _showSma200,
|
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
|
||||||
showSignals: _showSignals,
|
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
|
||||||
showSupertrend: _showSupertrend,
|
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.showChartOnly) {
|
if (showChartOnly) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
chartRibbon,
|
chartRibbon,
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
chartWidget,
|
chartWidget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final detailsSection = Padding(
|
final detailsSection = Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (patterns.isNotEmpty) ...[
|
||||||
children: [
|
const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
Row(
|
const SizedBox(height: 8),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
for (int i = 0; i < patterns.length; i++)
|
||||||
children: [
|
PatternCardItem(
|
||||||
Row(
|
pattern: patterns[i],
|
||||||
children: [
|
index: i,
|
||||||
Icon(Icons.architecture_outlined,
|
isEnabled: !state.disabledPatternIndices.contains(i),
|
||||||
color: AppTheme.primaryEmerald, size: 20),
|
onToggle: (enabled) {
|
||||||
const SizedBox(width: 8),
|
context.read<AssetTechnicalBloc>().add(
|
||||||
const Text('Erkannte Chart-Muster & Signale',
|
TogglePatternFilter(patternIndex: i, enabled: enabled),
|
||||||
style: TextStyle(
|
);
|
||||||
fontSize: 16,
|
},
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
color: Colors.white)),
|
const SizedBox(height: 16),
|
||||||
],
|
|
||||||
),
|
|
||||||
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)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
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(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: detailsSection,
|
child: detailsSection,
|
||||||
@@ -325,228 +173,26 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Keine technisches Indikatoren verfügbar',
|
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
|
||||||
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) {
|
Widget _buildTechnicalShimmer(BuildContext context) {
|
||||||
if (widget.showChartOnly) {
|
if (showChartOnly) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
const SizedBox(height: 8),
|
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(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -570,7 +216,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
children: [
|
children: [
|
||||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
const SizedBox(height: 8),
|
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 SizedBox(height: 16),
|
||||||
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import '../../../../core/theme/app_theme.dart';
|
|||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.dart';
|
import '../../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_model.dart';
|
import '../../../trades/models/trade_model.dart';
|
||||||
|
import '../../../trades/widgets/trade_execution_dialog.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 '../../bloc/trades/asset_trades_bloc.dart';
|
import '../../bloc/trades/asset_trades_bloc.dart';
|
||||||
import '../../bloc/trades/asset_trades_event.dart';
|
import '../../bloc/trades/asset_trades_event.dart';
|
||||||
import '../../bloc/trades/asset_trades_state.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 {
|
class TradesTab extends StatefulWidget {
|
||||||
final String symbol;
|
final String symbol;
|
||||||
@@ -22,13 +24,13 @@ class TradesTab extends StatefulWidget {
|
|||||||
|
|
||||||
class _TradesTabState extends State<TradesTab> {
|
class _TradesTabState extends State<TradesTab> {
|
||||||
bool _justTriggeredAnalysis = false;
|
bool _justTriggeredAnalysis = false;
|
||||||
|
LiveTradeSettings _settings = const LiveTradeSettings(
|
||||||
// Settings State
|
defaultPositionSize: 2500.0,
|
||||||
double _defaultPositionSize = 2500.0;
|
defaultLeverage: 5.0,
|
||||||
double _defaultLeverage = 5.0;
|
defaultRiskScore: 50.0,
|
||||||
double _defaultRiskScore = 50.0;
|
defaultOrderFee: 1.0,
|
||||||
double _defaultOrderFee = 1.0;
|
autoAcceptSignals: false,
|
||||||
bool _autoAcceptSignals = false;
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -36,410 +38,6 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
|
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}) {
|
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
|
||||||
final tradesBloc = context.read<AssetTradesBloc>();
|
final tradesBloc = context.read<AssetTradesBloc>();
|
||||||
|
|
||||||
@@ -453,9 +51,7 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
final tId = trade.id;
|
final tId = trade.id;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(isActive
|
content: Text(isActive ? 'Einstellungen für Trade $tId gespeichert!' : 'Trade $tId angenommen & Position eröffnet!'),
|
||||||
? 'Einstellungen für Trade $tId gespeichert!'
|
|
||||||
: 'Trade $tId angenommen & Position eröffnet!'),
|
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -497,7 +93,6 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Action Button & Settings Card
|
|
||||||
GlassContainer(
|
GlassContainer(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -525,7 +120,24 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
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),
|
icon: const Icon(Icons.auto_awesome, size: 18),
|
||||||
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -538,7 +150,13 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: () => _showLiveTradeSettingsDialog(context),
|
onPressed: () {
|
||||||
|
LiveTradeSettingsDialog.show(
|
||||||
|
context,
|
||||||
|
currentSettings: _settings,
|
||||||
|
onSave: (newSettings) => setState(() => _settings = newSettings),
|
||||||
|
);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.settings, color: Colors.white),
|
icon: const Icon(Icons.settings, color: Colors.white),
|
||||||
tooltip: 'Live Trade Einstellungen',
|
tooltip: 'Live Trade Einstellungen',
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
@@ -552,7 +170,6 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
if (state is AssetTradesLoading)
|
if (state is AssetTradesLoading)
|
||||||
_buildTradesShimmer(context)
|
_buildTradesShimmer(context)
|
||||||
else if (state is AssetTradesError)
|
else if (state is AssetTradesError)
|
||||||
@@ -618,313 +235,38 @@ class _TradesTabState extends State<TradesTab> {
|
|||||||
itemCount: trades.length,
|
itemCount: trades.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final trade = trades[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/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../utils/pattern_explanations.dart';
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import 'candlestick_painter.dart';
|
||||||
|
|
||||||
class CandleModel {
|
export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel;
|
||||||
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'] ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CandlestickChart extends StatefulWidget {
|
class CandlestickChart extends StatefulWidget {
|
||||||
final List<CandleModel> candles;
|
final List<CandleModel> candles;
|
||||||
final List<ChartPatternModel> patterns;
|
final List<ChartPatternModel> patterns;
|
||||||
final List<StrategySignalModel> signals;
|
final List<StrategySignalModel> signals;
|
||||||
final List<IndicatorModel> indicators;
|
final List<IndicatorModel> indicators;
|
||||||
final bool showPatterns;
|
|
||||||
final bool showSma50;
|
final bool showSma50;
|
||||||
final bool showSma200;
|
final bool showSma200;
|
||||||
final bool showEma;
|
final bool showEma;
|
||||||
|
final bool showPatterns;
|
||||||
final bool showSignals;
|
final bool showSignals;
|
||||||
final bool showSupertrend;
|
final bool showSupertrend;
|
||||||
|
final double height;
|
||||||
|
final bool isFullscreen;
|
||||||
|
final VoidCallback? onToggleFullscreen;
|
||||||
|
|
||||||
const CandlestickChart({
|
const CandlestickChart({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -126,12 +27,15 @@ class CandlestickChart extends StatefulWidget {
|
|||||||
this.patterns = const [],
|
this.patterns = const [],
|
||||||
this.signals = const [],
|
this.signals = const [],
|
||||||
this.indicators = const [],
|
this.indicators = const [],
|
||||||
this.showPatterns = true,
|
|
||||||
this.showSma50 = true,
|
this.showSma50 = true,
|
||||||
this.showSma200 = true,
|
this.showSma200 = true,
|
||||||
this.showEma = true,
|
this.showEma = true,
|
||||||
|
this.showPatterns = true,
|
||||||
this.showSignals = true,
|
this.showSignals = true,
|
||||||
this.showSupertrend = true,
|
this.showSupertrend = true,
|
||||||
|
this.height = 420,
|
||||||
|
this.isFullscreen = false,
|
||||||
|
this.onToggleFullscreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -141,81 +45,142 @@ class CandlestickChart extends StatefulWidget {
|
|||||||
class _CandlestickChartState extends State<CandlestickChart> {
|
class _CandlestickChartState extends State<CandlestickChart> {
|
||||||
double _scale = 1.0;
|
double _scale = 1.0;
|
||||||
double _panOffset = 0.0;
|
double _panOffset = 0.0;
|
||||||
|
double _baseScale = 1.0;
|
||||||
|
double _basePanOffset = 0.0;
|
||||||
|
Offset _startFocalPoint = Offset.zero;
|
||||||
|
bool _isDragging = false;
|
||||||
Offset? _tapPosition;
|
Offset? _tapPosition;
|
||||||
CandleModel? _selectedCandle;
|
CandleModel? _selectedCandle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void initState() {
|
||||||
if (widget.candles.isEmpty) {
|
super.initState();
|
||||||
return const Center(child: Text('No chart data'));
|
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;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return Container(
|
||||||
builder: (context, constraints) {
|
height: widget.height,
|
||||||
final double baseWidth = 10.0;
|
decoration: BoxDecoration(
|
||||||
final double spacing = 5.0;
|
color: theme.cardSurface,
|
||||||
final double totalCandleSpace = (baseWidth + spacing) * _scale;
|
borderRadius: BorderRadius.circular(16),
|
||||||
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
|
border: Border.all(color: theme.glassBorder),
|
||||||
|
),
|
||||||
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
|
child: ClipRRect(
|
||||||
final double maxOffset = 100.0;
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Listener(
|
||||||
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
|
|
||||||
|
|
||||||
return Listener(
|
|
||||||
onPointerSignal: (pointerSignal) {
|
onPointerSignal: (pointerSignal) {
|
||||||
if (pointerSignal is PointerScrollEvent) {
|
if (pointerSignal is PointerScrollEvent) {
|
||||||
GestureBinding.instance.pointerSignalResolver.register(
|
if (pointerSignal.scrollDelta.dx != 0) {
|
||||||
pointerSignal,
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
(event) {
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
if (event is PointerScrollEvent) {
|
setState(() {
|
||||||
setState(() {
|
_panOffset -= pointerSignal.scrollDelta.dx;
|
||||||
final double localX = event.localPosition.dx;
|
_clampPanOffset(chartWidth);
|
||||||
final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1;
|
});
|
||||||
final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0);
|
} else if (pointerSignal.scrollDelta.dy != 0) {
|
||||||
final double scaleRatio = newScale / _scale;
|
final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85;
|
||||||
|
_applyZoom(zoomFactor, pointerSignal.localPosition.dx);
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: GestureDetector(
|
child: MouseRegion(
|
||||||
onScaleUpdate: (details) {
|
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
|
||||||
setState(() {
|
child: GestureDetector(
|
||||||
_scale = (_scale * details.scale).clamp(0.2, 5.0);
|
behavior: HitTestBehavior.opaque,
|
||||||
_panOffset += details.focalPointDelta.dx;
|
onScaleStart: (details) {
|
||||||
_panOffset = _panOffset.clamp(minOffset, maxOffset);
|
_baseScale = _scale;
|
||||||
if (_tapPosition != null) {
|
_basePanOffset = _panOffset;
|
||||||
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
|
_startFocalPoint = details.focalPoint;
|
||||||
}
|
setState(() => _isDragging = true);
|
||||||
});
|
},
|
||||||
},
|
onScaleUpdate: (details) {
|
||||||
onScaleEnd: (_) => setState(() {
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
_tapPosition = null;
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
_selectedCandle = null;
|
setState(() {
|
||||||
}),
|
if (details.scale != 1.0) {
|
||||||
onTapDown: (details) {
|
final oldScale = _scale;
|
||||||
_handleTap(details.localPosition, constraints.maxWidth);
|
_scale = (_baseScale * details.scale).clamp(0.1, 6.0);
|
||||||
},
|
final fx = details.localFocalPoint.dx;
|
||||||
child: Stack(
|
_panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale));
|
||||||
children: [
|
} else {
|
||||||
ClipRect(
|
_panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx);
|
||||||
child: CustomPaint(
|
}
|
||||||
|
_clampPanOffset(chartWidth);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onScaleEnd: (details) {
|
||||||
|
setState(() => _isDragging = false);
|
||||||
|
},
|
||||||
|
onTapDown: (details) {
|
||||||
|
_handleTap(details.localPosition);
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
size: Size.infinite,
|
size: Size.infinite,
|
||||||
painter: _CandlePainter(
|
painter: CandlestickPainter(
|
||||||
candles: widget.candles,
|
candles: widget.candles,
|
||||||
patterns: widget.patterns,
|
patterns: widget.patterns,
|
||||||
signals: widget.signals,
|
signals: widget.signals,
|
||||||
@@ -232,74 +197,76 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
tapPosition: _tapPosition,
|
tapPosition: _tapPosition,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_selectedCandle != null) _buildTooltip(theme),
|
||||||
if (_selectedCandle != null) _buildTooltip(theme),
|
_buildZoomControls(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',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
if (widget.candles.isEmpty) return;
|
||||||
|
final double candleWidth = 10.0 * _scale;
|
||||||
// Right side is for axis, don't tap there
|
final double totalCandleSpace = candleWidth + (5.0 * _scale);
|
||||||
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 int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
|
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
|
||||||
|
|
||||||
if (index >= 0 && index < widget.candles.length) {
|
if (index >= 0 && index < widget.candles.length) {
|
||||||
@@ -311,8 +278,8 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTooltip(ThemePreset theme) {
|
Widget _buildTooltip(ThemePreset theme) {
|
||||||
final candle = _selectedCandle!;
|
final c = _selectedCandle!;
|
||||||
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
|
final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}";
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 10,
|
left: 10,
|
||||||
@@ -328,492 +295,12 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
|
Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
|
||||||
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, 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('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)),
|
||||||
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)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/asset_logo_widget.dart';
|
import '../../../../core/widgets/asset_logo_widget.dart';
|
||||||
import '../../../../shared/widgets/favorite_star_button.dart';
|
import '../../../../shared/widgets/favorite_star_button.dart';
|
||||||
import '../../bloc/header/asset_header_bloc.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||||
import '../../bloc/header/asset_header_state.dart';
|
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||||
import '../../bloc/technical/asset_technical_bloc.dart';
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_state.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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AssetHeroHeader extends StatelessWidget {
|
class AssetHeroHeader extends StatelessWidget {
|
||||||
@@ -20,35 +20,31 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
const AssetHeroHeader({
|
const AssetHeroHeader({
|
||||||
super.key,
|
super.key,
|
||||||
this.onExchangeChanged,
|
this.onExchangeChanged,
|
||||||
this.onForceRefresh, required this.isin, required this.name, this.symbol,
|
this.onForceRefresh,
|
||||||
|
required this.isin,
|
||||||
|
required this.name,
|
||||||
|
this.symbol,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return BlocBuilder<AssetHeaderBloc, AssetHeaderState>(
|
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
|
||||||
builder: (context, state) {
|
builder: (context, fundState) {
|
||||||
double? price;
|
String displayName = name;
|
||||||
String currency = 'EUR';
|
final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null;
|
||||||
List<AssetTickerOption> tickerOptions = [
|
List<TickerModel> tickerOptions = [
|
||||||
AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0)
|
TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null)
|
||||||
];
|
];
|
||||||
|
|
||||||
AssetModel? asset;
|
if (fundState is AssetFundamentalsLoaded && fundState.data != null) {
|
||||||
if (state is AssetHeaderLoaded) {
|
final data = fundState.data!;
|
||||||
asset = state.data;
|
if (data.companyName.isNotEmpty) {
|
||||||
} else if (state is AssetHeaderLoading) {
|
displayName = data.companyName;
|
||||||
asset = state.previousData;
|
}
|
||||||
}
|
if (data.availableTickers.isNotEmpty) {
|
||||||
|
tickerOptions = data.availableTickers;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +84,14 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
AssetLogoWidget(symbolOrName: isin, imageUrl: asset?.image, size: 48),
|
AssetLogoWidget(symbolOrName: isin, imageUrl: logoUrl, size: 48),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SelectableText(
|
SelectableText(
|
||||||
name,
|
displayName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
@@ -138,7 +134,7 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
onPressed: onForceRefresh,
|
onPressed: onForceRefresh,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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: [
|
children: [
|
||||||
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
builder: (context, taState) {
|
builder: (context, taState) {
|
||||||
double? livePrice = price;
|
double? livePrice = selectedOption.currentPrice;
|
||||||
String liveCurrency = selectedOption.tradingCurrency.isNotEmpty
|
String liveCurrency = selectedOption.tradingCurrency ?? 'EUR';
|
||||||
? selectedOption.tradingCurrency
|
|
||||||
: currency;
|
|
||||||
|
|
||||||
if (taState is AssetTechnicalLoaded && taState.data != null) {
|
if (taState is AssetTechnicalLoaded && taState.data != null) {
|
||||||
if (taState.data!.candles.isNotEmpty) {
|
if (taState.data!.candles.isNotEmpty) {
|
||||||
@@ -219,12 +213,12 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
(t) => t.ticker == newTicker,
|
(t) => t.ticker == newTicker,
|
||||||
orElse: () => tickerOptions.first,
|
orElse: () => tickerOptions.first,
|
||||||
);
|
);
|
||||||
onExchangeChanged!(opt.exchange, opt.ticker);
|
onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
return tickerOptions.map((opt) {
|
return tickerOptions.map((opt) {
|
||||||
final ex = opt.exchange;
|
final ex = opt.exchange ?? 'Unknown';
|
||||||
final tick = opt.ticker;
|
final tick = opt.ticker;
|
||||||
final label = '$tick ($ex)';
|
final label = '$tick ($ex)';
|
||||||
final isSelected = tick == symbol || ex == symbol;
|
final isSelected = tick == symbol || ex == symbol;
|
||||||
@@ -263,7 +257,7 @@ class AssetHeroHeader extends StatelessWidget {
|
|||||||
Icon(Icons.business, size: 14, color: theme.accentColor),
|
Icon(Icons.business, size: 14, color: theme.accentColor),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'${selectedOption.ticker} (${selectedOption.exchange})',
|
'${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.bold,
|
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 {
|
Future<void> logout() async {
|
||||||
await storageService.clearAll();
|
await storageService.clearAll();
|
||||||
}
|
}
|
||||||
@@ -76,3 +84,4 @@ class RequiresPasswordChangeException implements Exception {
|
|||||||
final String userId;
|
final String userId;
|
||||||
RequiresPasswordChangeException(this.userId);
|
RequiresPasswordChangeException(this.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:finlytic_app/core/network/api_client.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/core/theme/app_theme.dart';
|
||||||
import 'package:finlytic_app/features/auth/bloc/auth_bloc.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 {
|
class ChangeInitialPasswordScreen extends StatefulWidget {
|
||||||
final String userId;
|
final String userId;
|
||||||
@@ -23,13 +25,13 @@ class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScree
|
|||||||
if (_formKey.currentState?.validate() ?? false) {
|
if (_formKey.currentState?.validate() ?? false) {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final apiClient = context.read<ApiClient>();
|
final authRepo = AuthRepository(
|
||||||
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
|
apiClient: context.read<ApiClient>(),
|
||||||
'userId': widget.userId,
|
storageService: context.read<SecureStorageService>(),
|
||||||
'newPassword': _passwordController.text,
|
);
|
||||||
});
|
final success = await authRepo.changeInitialPassword(widget.userId, _passwordController.text);
|
||||||
|
|
||||||
if (res.statusCode == 200) {
|
if (success) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.'), backgroundColor: AppTheme.accentCyan),
|
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 String eventType;
|
||||||
final DateTime eventDate;
|
final DateTime eventDate;
|
||||||
final String description;
|
final String description;
|
||||||
|
final String? image;
|
||||||
|
final String? ticker;
|
||||||
|
|
||||||
const CorporateEventModel({
|
const CorporateEventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -17,6 +19,8 @@ class CorporateEventModel extends Equatable {
|
|||||||
required this.eventDate,
|
required this.eventDate,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.isin,
|
required this.isin,
|
||||||
|
this.image,
|
||||||
|
this.ticker,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CorporateEventModel.fromJson(Map<String, dynamic> json) {
|
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(
|
return CorporateEventModel(
|
||||||
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
||||||
isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? '',
|
isin: isinVal,
|
||||||
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
|
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
|
||||||
companyName: json['companyName']?.toString() ??
|
companyName: json['companyName']?.toString() ??
|
||||||
json['CompanyName']?.toString() ??
|
json['CompanyName']?.toString() ??
|
||||||
@@ -50,6 +57,8 @@ class CorporateEventModel extends Equatable {
|
|||||||
description: json['description']?.toString() ??
|
description: json['description']?.toString() ??
|
||||||
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,
|
'eventType': eventType,
|
||||||
'eventDate': eventDate.toIso8601String(),
|
'eventDate': eventDate.toIso8601String(),
|
||||||
'description': description,
|
'description': description,
|
||||||
|
if (image != null) 'image': image,
|
||||||
|
if (ticker != null) 'ticker': ticker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
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 'package:flutter/material.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/asset_logo_widget.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
import '../../../core/widgets/glass_container.dart';
|
||||||
import '../../../core/widgets/status_badge.dart';
|
import '../../../core/widgets/status_badge.dart';
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ class CalendarEventItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isin = event['isin']?.toString() ?? '';
|
||||||
final symbol = event['symbol']?.toString() ?? 'ASSET';
|
final symbol = event['symbol']?.toString() ?? 'ASSET';
|
||||||
final company = event['companyName']?.toString() ?? symbol;
|
final company = event['companyName']?.toString() ?? symbol;
|
||||||
final type = event['eventType']?.toString() ?? 'Earnings';
|
final type = event['eventType']?.toString() ?? 'Earnings';
|
||||||
final desc = event['description']?.toString() ?? '';
|
final desc = event['description']?.toString() ?? '';
|
||||||
final dateStr = event['eventDate']?.toString() ?? '';
|
final dateStr = event['eventDate']?.toString() ?? '';
|
||||||
|
final image = event['image']?.toString() ?? (isin.isNotEmpty ? '/api/v1/logo/$isin' : null);
|
||||||
|
|
||||||
Color badgeColor = AppTheme.primaryEmerald;
|
Color badgeColor = AppTheme.primaryEmerald;
|
||||||
if (type == 'ExDividend') badgeColor = AppTheme.accentCyan;
|
if (type == 'ExDividend') badgeColor = AppTheme.accentCyan;
|
||||||
@@ -25,16 +28,11 @@ class CalendarEventItem extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
AssetLogoWidget(
|
||||||
padding: const EdgeInsets.all(10),
|
symbolOrName: isin.isNotEmpty ? isin : company,
|
||||||
decoration: BoxDecoration(
|
imageUrl: image,
|
||||||
color: badgeColor.withValues(alpha: 0.15),
|
size: 36,
|
||||||
borderRadius: BorderRadius.circular(10),
|
enableHero: false,
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
type == 'Earnings' ? Icons.bar_chart : (type == 'ExDividend' ? Icons.content_cut : Icons.payments),
|
|
||||||
color: badgeColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class CalendarEventTile extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
AssetLogoWidget(symbolOrName: companyName, imageUrl: image, size: 28),
|
AssetLogoWidget(symbolOrName: isin.isNotEmpty ? isin : companyName, imageUrl: image, size: 28, enableHero: false),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../models/discovery_asset_model.dart';
|
import '../models/discovery_asset_model.dart';
|
||||||
|
import '../repositories/discovery_repository.dart';
|
||||||
|
|
||||||
class DiscoveryState extends Equatable {
|
class DiscoveryState extends Equatable {
|
||||||
final List<DiscoveryAssetModel> assets;
|
final List<DiscoveryAssetModel> assets;
|
||||||
@@ -27,27 +28,24 @@ class DiscoveryState extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DiscoveryCubit extends Cubit<DiscoveryState> {
|
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 {
|
Future<void> loadDiscovery({int limit = 15}) async {
|
||||||
if (state.isLoading) return;
|
if (state.isLoading) return;
|
||||||
emit(state.copyWith(isLoading: true));
|
emit(state.copyWith(isLoading: true));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await apiClient.get('/api/v1/assets/discovery?limit=$limit');
|
final list = await repository.getDiscoveryAssets(limit: limit);
|
||||||
if (res.statusCode == 200 && res.data is List) {
|
emit(DiscoveryState(assets: list, isLoading: false));
|
||||||
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));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
emit(state.copyWith(isLoading: false));
|
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/api_client.dart';
|
||||||
import '../../../core/network/signalr_service.dart';
|
import '../../../core/network/signalr_service.dart';
|
||||||
import '../models/favorite_asset_model.dart';
|
import '../models/favorite_asset_model.dart';
|
||||||
|
import '../repositories/favorites_repository.dart';
|
||||||
|
|
||||||
class FavoritesState extends Equatable {
|
class FavoritesState extends Equatable {
|
||||||
final Set<String> favoriteIsins;
|
final Set<String> favoriteIsins;
|
||||||
@@ -39,11 +40,16 @@ class FavoritesState extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FavoritesCubit extends Cubit<FavoritesState> {
|
class FavoritesCubit extends Cubit<FavoritesState> {
|
||||||
final ApiClient apiClient;
|
final FavoritesRepository repository;
|
||||||
final SignalRService? signalRService;
|
final SignalRService? signalRService;
|
||||||
StreamSubscription<Map<String, dynamic>>? _priceSub;
|
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
|
// 1. Subscribe to SignalR 10-second WebSocket price stream
|
||||||
if (signalRService != null) {
|
if (signalRService != null) {
|
||||||
_priceSub = signalRService!.favoritePricesStream.listen((priceMap) {
|
_priceSub = signalRService!.favoritePricesStream.listen((priceMap) {
|
||||||
@@ -58,36 +64,24 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
|||||||
return super.close();
|
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 {
|
Future<void> loadFavorites() async {
|
||||||
emit(state.copyWith(isLoading: true));
|
emit(state.copyWith(isLoading: true));
|
||||||
try {
|
try {
|
||||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
final list = await repository.getFavorites();
|
||||||
final res = await apiClient.get('/api/v1/user/favorites?_t=$ts');
|
final set = <String>{};
|
||||||
if (res.statusCode == 200 && res.data is List) {
|
|
||||||
final rawList = res.data as List;
|
|
||||||
final set = <String>{};
|
|
||||||
final dedupMap = <String, FavoriteAssetModel>{};
|
|
||||||
|
|
||||||
for (var item in rawList) {
|
for (var model in list) {
|
||||||
final model = FavoriteAssetModel.fromJson(Map<String, dynamic>.from(item));
|
if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase());
|
||||||
final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase();
|
if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase());
|
||||||
if (!dedupMap.containsKey(key)) {
|
if (model.name.isNotEmpty) set.add(model.name.toUpperCase());
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit(FavoritesState(
|
||||||
|
favoriteIsins: set,
|
||||||
|
favoriteDetails: list,
|
||||||
|
isLoading: false,
|
||||||
|
));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
emit(state.copyWith(isLoading: false));
|
emit(state.copyWith(isLoading: false));
|
||||||
}
|
}
|
||||||
@@ -132,17 +126,15 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
|||||||
|
|
||||||
emit(state.copyWith(favoriteIsins: newSet));
|
emit(state.copyWith(favoriteIsins: newSet));
|
||||||
|
|
||||||
// Perform API call in background
|
// Perform API call via repository
|
||||||
try {
|
try {
|
||||||
if (isCurrentlyFav) {
|
if (isCurrentlyFav) {
|
||||||
await apiClient.delete('/api/v1/user/favorites/$target');
|
await repository.removeFavorite(target);
|
||||||
} else {
|
} 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();
|
await loadFavorites();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Revert on error
|
|
||||||
await loadFavorites();
|
await loadFavorites();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,11 +151,11 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
emit(state.copyWith(favoriteDetails: updatedDetails));
|
emit(state.copyWith(favoriteDetails: updatedDetails));
|
||||||
|
|
||||||
await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker');
|
await repository.updateFavoriteTicker(symbol, ticker);
|
||||||
await loadFavorites();
|
await loadFavorites();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Revert/refresh on error
|
|
||||||
await loadFavorites();
|
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) {
|
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
|
||||||
List<MatchedAssetModel> assets = [];
|
List<MatchedAssetModel> assets = [];
|
||||||
final mList = json['matchedAssets'] ?? json['MatchedAssets'];
|
final mList = json['matchedAssets'];
|
||||||
if (mList != null && mList is List) {
|
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(
|
return NewsArticleModel(
|
||||||
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
id: json['id']?.toString() ?? '',
|
||||||
title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title',
|
title: json['title']?.toString() ?? 'No Title',
|
||||||
author: json['author']?.toString() ?? json['Author']?.toString() ?? 'Unknown',
|
author: json['author']?.toString() ?? 'Unknown',
|
||||||
summary: json['summary']?.toString() ?? json['Summary']?.toString() ?? '',
|
summary: json['summary']?.toString() ?? '',
|
||||||
contentRaw: json['contentRaw']?.toString() ?? json['ContentRaw']?.toString() ?? '',
|
contentRaw: json['contentRaw']?.toString() ?? '',
|
||||||
sourceUrl: json['sourceUrl']?.toString() ?? json['SourceUrl']?.toString() ?? '',
|
sourceUrl: json['sourceUrl']?.toString() ?? '',
|
||||||
scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? json['ScrapedAt']?.toString() ?? '') ?? DateTime.now(),
|
scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? json['PublishedAt']?.toString() ?? '') ?? DateTime.now(),
|
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
status: json['status']?.toString() ?? json['Status']?.toString() ?? 'Completed',
|
status: json['status']?.toString() ?? 'Completed',
|
||||||
sentiment: json['sentiment']?.toString() ?? json['Sentiment']?.toString() ?? '',
|
sentiment: json['sentiment']?.toString() ?? '',
|
||||||
|
sentimentScore: (json['sentimentScore'] as num?)?.toDouble() ?? 0.0,
|
||||||
sentimentScore: (json['sentimentScore'] ?? json['SentimentScore'] ?? 0.0).toDouble(),
|
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||||||
confidence: (json['confidence'] ?? json['Confidence'] ?? 0.0).toDouble(),
|
finbertResult: json['finbertResult'] != null
|
||||||
finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null)
|
? FinbertResultModel.fromJson(json['finbertResult'] as Map<String, dynamic>)
|
||||||
? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult'])
|
|
||||||
: null,
|
: null,
|
||||||
matchedAssets: assets,
|
matchedAssets: assets,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class NewsRepository {
|
|||||||
String? symbol,
|
String? symbol,
|
||||||
String? isin,
|
String? isin,
|
||||||
String? date,
|
String? date,
|
||||||
|
String? query,
|
||||||
|
bool? hasSentiment,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final Map<String, dynamic> queryParams = {
|
final Map<String, dynamic> queryParams = {
|
||||||
@@ -33,17 +35,21 @@ class NewsRepository {
|
|||||||
if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol;
|
if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol;
|
||||||
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
|
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
|
||||||
if (date != null && date.isNotEmpty) queryParams['date'] = date;
|
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);
|
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;
|
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 [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error fetching news: $e');
|
throw Exception('Failed to load news: $e');
|
||||||
throw Exception('Failed to load news');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/theme/app_theme.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/news_card_item.dart';
|
||||||
import '../widgets/advanced_news_filter_bar.dart';
|
import '../widgets/advanced_news_filter_bar.dart';
|
||||||
|
|
||||||
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting.
|
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting.
|
||||||
class NewsFeedScreen extends StatefulWidget {
|
class NewsFeedScreen extends StatefulWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
final NewsRepository? repository;
|
||||||
|
|
||||||
const NewsFeedScreen({super.key, required this.apiClient});
|
const NewsFeedScreen({super.key, required this.apiClient, this.repository});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NewsFeedScreen> createState() => _NewsFeedScreenState();
|
State<NewsFeedScreen> createState() => _NewsFeedScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||||
|
late final NewsRepository _repository;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final List<dynamic> _newsItems = [];
|
final List<NewsArticleModel> _newsItems = [];
|
||||||
int _currentPage = 1;
|
int _currentPage = 1;
|
||||||
static const int _pageSize = 15;
|
static const int _pageSize = 15;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
@@ -34,6 +38,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_repository = widget.repository ?? NewsRepository(apiClient: widget.apiClient, backendUrl: ApiClient.baseUrl);
|
||||||
_loadNews(refresh: true);
|
_loadNews(refresh: true);
|
||||||
_scrollController.addListener(() {
|
_scrollController.addListener(() {
|
||||||
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||||
@@ -49,17 +54,6 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
|||||||
super.dispose();
|
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 {
|
Future<void> _loadNews({bool refresh = false}) async {
|
||||||
if (_isLoading) return;
|
if (_isLoading) return;
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
@@ -72,54 +66,32 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final queryParams = <String, dynamic>{
|
final fetched = await _repository.fetchNews(
|
||||||
'page': _currentPage,
|
page: _currentPage,
|
||||||
'pageSize': _pageSize,
|
pageSize: _pageSize,
|
||||||
};
|
date: _selectedDate?.toIso8601String().substring(0, 10),
|
||||||
|
query: _searchQuery?.trim(),
|
||||||
|
isin: _selectedIsin?.trim(),
|
||||||
|
hasSentiment: _hasSentimentOnly,
|
||||||
|
);
|
||||||
|
|
||||||
if (_selectedDate != null) {
|
setState(() {
|
||||||
queryParams['date'] = _selectedDate!.toIso8601String().substring(0, 10);
|
final existingIds = _newsItems.map((e) => e.id).where((id) => id.isNotEmpty).toSet();
|
||||||
}
|
for (final item in fetched) {
|
||||||
if (_searchQuery != null && _searchQuery!.trim().isNotEmpty) {
|
if (item.id.isEmpty || !existingIds.contains(item.id)) {
|
||||||
queryParams['query'] = _searchQuery!.trim();
|
_newsItems.add(item);
|
||||||
}
|
if (item.id.isNotEmpty) existingIds.add(item.id);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-sort strictly by publication timestamp descending (newest articles at the top)
|
_newsItems.sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
|
||||||
_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);
|
|
||||||
});
|
|
||||||
|
|
||||||
_currentPage++;
|
_currentPage++;
|
||||||
if (fetched.length < _pageSize) {
|
if (fetched.length < _pageSize) {
|
||||||
_hasMore = false;
|
_hasMore = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Handle error visually if necessary, currently silent fallback
|
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import '../models/news_article_model.dart';
|
import '../models/news_article_model.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/status_badge.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 {
|
class ArticleSentimentDialog extends StatefulWidget {
|
||||||
final NewsArticleModel articleData;
|
final NewsArticleModel articleData;
|
||||||
|
|
||||||
@@ -32,17 +32,6 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
|
|||||||
super.dispose();
|
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 {
|
void _openOriginalSource() async {
|
||||||
final urlStr = widget.articleData.sourceUrl;
|
final urlStr = widget.articleData.sourceUrl;
|
||||||
if (urlStr.isNotEmpty) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = _findString(['Title', 'title']) ?? 'Nachrichtenartikel';
|
final article = widget.articleData;
|
||||||
final author = _findString(['Author', 'author', 'Source', 'source']) ?? 'Finlytic News';
|
final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel';
|
||||||
final summary = _findString(['Summary', 'summary']);
|
final author = article.author.isNotEmpty ? article.author : 'Finlytic News';
|
||||||
final sourceUrl = _findString(['SourceUrl', 'sourceUrl']);
|
final summary = article.summary;
|
||||||
final contentRaw = _findString(['ContentRaw', 'contentRaw', 'content', 'Text', 'text']);
|
final sourceUrl = article.sourceUrl;
|
||||||
final publishedAt = _findString(['PublishedAt', 'publishedAt', 'ScrapedAt', 'scrapedAt']) ?? '';
|
final contentRaw = article.contentRaw;
|
||||||
final status = _findString(['Status', 'status']) ?? 'Completed';
|
final publishedAt = "${article.publishedAt.day}.${article.publishedAt.month}.${article.publishedAt.year}";
|
||||||
final rawSentiment = _findString(['sentiment', 'Sentiment', 'sentimentLabel', 'SentimentLabel']);
|
final status = article.status.isNotEmpty ? article.status : 'Completed';
|
||||||
|
final rawSentiment = article.sentiment;
|
||||||
|
|
||||||
final articleMap = widget.articleData.toJson();
|
final compoundScore = article.finbertResult?.score ?? article.sentimentScore;
|
||||||
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 double compoundScore = widget.articleData.finbertResult?.score ?? widget.articleData.sentimentScore;
|
final Widget listBadge = rawSentiment.isNotEmpty
|
||||||
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
|
|
||||||
? StatusBadge.sentiment(rawSentiment.toUpperCase(), score: compoundScore)
|
? StatusBadge.sentiment(rawSentiment.toUpperCase(), score: compoundScore)
|
||||||
: StatusBadge(
|
: StatusBadge(
|
||||||
label: status.toUpperCase(),
|
label: status.toUpperCase(),
|
||||||
color: status.toLowerCase().contains('analyz') || status.toLowerCase().contains('klassifi')
|
color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan,
|
||||||
? AppTheme.primaryEmerald
|
|
||||||
: AppTheme.accentCyan,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
|
backgroundColor: AppTheme.cardSurface,
|
||||||
child: Container(
|
shape: RoundedRectangleBorder(
|
||||||
width: 700,
|
borderRadius: BorderRadius.circular(16),
|
||||||
height: 620,
|
side: BorderSide(color: AppTheme.glassBorder),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
width: 650,
|
||||||
decoration: BoxDecoration(
|
height: 600,
|
||||||
color: AppTheme.glassSurface,
|
padding: const EdgeInsets.all(20),
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: AppTheme.glassBorder),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
title,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textMuted, fontWeight: FontWeight.bold),
|
children: [
|
||||||
overflow: TextOverflow.ellipsis,
|
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(
|
const SizedBox(width: 8),
|
||||||
message: tooltipText,
|
listBadge,
|
||||||
padding: const EdgeInsets.all(12),
|
IconButton(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
onPressed: () => Navigator.pop(context),
|
||||||
decoration: BoxDecoration(
|
icon: const Icon(Icons.close, color: Colors.white70),
|
||||||
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(height: 6),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Container(
|
||||||
valueText,
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: accentColor),
|
color: AppTheme.glassSurface,
|
||||||
),
|
borderRadius: BorderRadius.circular(10),
|
||||||
if (progressValue != null) ...[
|
border: Border.all(color: AppTheme.glassBorder),
|
||||||
const SizedBox(height: 6),
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: progressValue!.clamp(0.0, 1.0),
|
|
||||||
backgroundColor: AppTheme.glassBorder,
|
|
||||||
color: accentColor,
|
|
||||||
minHeight: 6,
|
|
||||||
),
|
),
|
||||||
],
|
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;
|
return entryPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double get actualExitPrice => currentPrice;
|
||||||
|
|
||||||
double get calculatedPnlAbs {
|
double get calculatedPnlAbs {
|
||||||
if (isClosed && pnlAbsolute != 0) return pnlAbsolute;
|
if (isClosed && pnlAbsolute != 0) return pnlAbsolute;
|
||||||
final curr = currentPrice;
|
final curr = currentPrice;
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import 'dart:async';
|
|||||||
import 'package:finlytic_app/core/network/api_client.dart';
|
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_model.dart';
|
||||||
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.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 {
|
class TradeRepository {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
TradeRepository({required this.apiClient});
|
const TradeRepository({required this.apiClient});
|
||||||
|
|
||||||
Future<List<TradeModel>> fetchTrades({String? isin, String? status}) async {
|
Future<List<TradeModel>> fetchTrades({String? isin, String? status}) async {
|
||||||
try {
|
try {
|
||||||
@@ -24,8 +25,7 @@ class TradeRepository {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error fetching trades: $e');
|
throw Exception('Trades konnten nicht geladen werden: $e');
|
||||||
throw Exception('Trades konnten nicht geladen werden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +36,15 @@ class TradeRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> closeTrade(String id, {double? exitPrice}) async {
|
Future<void> rejectTrade(String tradeId) async {
|
||||||
final body = exitPrice != null ? {'userExitPrice': exitPrice} : null;
|
final response = await apiClient.post('/api/v1/user/trades/$tradeId/reject');
|
||||||
final response = await apiClient.post('/api/v1/user/trades/$id/close', data: body);
|
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) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Trade konnte nicht geschlossen werden');
|
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/api_client.dart';
|
||||||
import '../../../core/network/signalr_service.dart';
|
import '../../../core/network/signalr_service.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
|
||||||
import '../../auth/bloc/auth_bloc.dart';
|
import '../../auth/bloc/auth_bloc.dart';
|
||||||
|
|
||||||
import '../bloc/trade_bloc.dart';
|
import '../bloc/trade_bloc.dart';
|
||||||
import '../bloc/trade_event.dart';
|
import '../bloc/trade_event.dart';
|
||||||
import '../bloc/trade_state.dart';
|
import '../bloc/trade_state.dart';
|
||||||
@@ -16,6 +14,7 @@ import '../widgets/trade_card.dart';
|
|||||||
import '../widgets/proposed_auto_trades_card.dart';
|
import '../widgets/proposed_auto_trades_card.dart';
|
||||||
import '../widgets/trade_acceptance_dialog.dart';
|
import '../widgets/trade_acceptance_dialog.dart';
|
||||||
import '../widgets/trade_execution_dialog.dart';
|
import '../widgets/trade_execution_dialog.dart';
|
||||||
|
import '../widgets/trade_performance_bar.dart';
|
||||||
|
|
||||||
class TradesFeedScreen extends StatelessWidget {
|
class TradesFeedScreen extends StatelessWidget {
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
@@ -46,7 +45,7 @@ class _TradesFeedScreenContent extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||||
String _selectedFilter = 'Offen'; // 'Alle', 'Offen', 'Vorschläge', 'Geschlossen'
|
String _selectedFilter = 'Offen';
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
final TextEditingController _searchCtrl = TextEditingController();
|
final TextEditingController _searchCtrl = TextEditingController();
|
||||||
|
|
||||||
@@ -96,7 +95,6 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Title & Reload Row
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -115,25 +113,18 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () => context.read<TradeBloc>().add(const FetchTrades()),
|
||||||
context.read<TradeBloc>().add(const FetchTrades());
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||||
tooltip: 'Trades Aktualisieren',
|
tooltip: 'Trades Aktualisieren',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Main Content Body
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<TradeBloc, TradeState>(
|
child: BlocBuilder<TradeBloc, TradeState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is TradeLoading) {
|
if (state is TradeLoading) {
|
||||||
return Center(
|
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||||
child: CircularProgressIndicator(color: AppTheme.primaryEmerald),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is TradeError) {
|
if (state is TradeError) {
|
||||||
@@ -157,21 +148,11 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
|
|
||||||
if (state is TradeLoaded) {
|
if (state is TradeLoaded) {
|
||||||
final allTrades = state.trades;
|
final allTrades = state.trades;
|
||||||
|
|
||||||
// Separate proposals, active, closed, and rejected trades
|
|
||||||
final proposals = allTrades.where((t) => t.isProposed).toList();
|
final proposals = allTrades.where((t) => t.isProposed).toList();
|
||||||
final activeTrades = allTrades.where((t) => t.isActive).toList();
|
final activeTrades = allTrades.where((t) => t.isActive).toList();
|
||||||
final closedTrades = allTrades.where((t) => t.isClosed).toList();
|
final closedTrades = allTrades.where((t) => t.isClosed).toList();
|
||||||
final rejectedTrades = allTrades.where((t) => t.isRejected).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;
|
List<TradeModel> filteredList = allTrades;
|
||||||
if (_selectedFilter == 'Alle') {
|
if (_selectedFilter == 'Alle') {
|
||||||
filteredList = allTrades.where((t) => !t.isProposed).toList();
|
filteredList = allTrades.where((t) => !t.isProposed).toList();
|
||||||
@@ -195,42 +176,21 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
// 1. Performance Overview Bar
|
TradePerformanceBar(
|
||||||
GlassContainer(
|
activeTrades: activeTrades,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
allTrades: allTrades,
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
proposals: proposals,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. Featured Card: Auto KI Trade Proposals
|
|
||||||
ProposedAutoTradesCard(
|
ProposedAutoTradesCard(
|
||||||
proposals: proposals,
|
proposals: proposals,
|
||||||
onAcceptProposal: (trade) => _handleAcceptProposal(context, trade),
|
onAcceptProposal: (trade) => _handleAcceptProposal(context, trade),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. Search & Filter Section
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchCtrl,
|
controller: _searchCtrl,
|
||||||
onChanged: (val) {
|
onChanged: (val) => setState(() => _searchQuery = val),
|
||||||
setState(() {
|
|
||||||
_searchQuery = val;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Suche nach Symbol, ISIN oder Name...',
|
hintText: 'Suche nach Symbol, ISIN oder Name...',
|
||||||
@@ -239,23 +199,14 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12),
|
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))),
|
||||||
borderRadius: BorderRadius.circular(10),
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))),
|
||||||
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),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Filter Chips Row
|
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -268,10 +219,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 4. Trades List
|
|
||||||
if (filteredList.isEmpty)
|
if (filteredList.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||||
@@ -280,10 +228,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
|
Icon(Icons.inbox, size: 40, color: AppTheme.textMuted),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('Keine Trades in der Kategorie "$_selectedFilter" gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||||
'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) {
|
Widget _filterChip(String label, int count) {
|
||||||
final isSelected = _selectedFilter == label;
|
final isSelected = _selectedFilter == label;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () => setState(() => _selectedFilter = label),
|
||||||
setState(() {
|
|
||||||
_selectedFilter = label;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(right: 8),
|
margin: const EdgeInsets.only(right: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.06),
|
color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.06),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.12)),
|
||||||
color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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 'package:flutter/material.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../models/trade_model.dart';
|
import '../models/trade_model.dart';
|
||||||
|
import 'trade_detail_content.dart';
|
||||||
|
|
||||||
class TradeDetailModal extends StatelessWidget {
|
class TradeDetailModal extends StatelessWidget {
|
||||||
final TradeModel trade;
|
final TradeModel trade;
|
||||||
@@ -36,11 +37,6 @@ class TradeDetailModal extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isBuy = trade.signalType == 'BUY';
|
final isBuy = trade.signalType == 'BUY';
|
||||||
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
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(
|
return Container(
|
||||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||||
@@ -60,7 +56,6 @@ class TradeDetailModal extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Handle Bar
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -70,8 +65,6 @@ class TradeDetailModal extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Modal Header
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -134,172 +127,14 @@ class TradeDetailModal extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(color: Colors.white10, height: 1),
|
const Divider(color: Colors.white10, height: 1),
|
||||||
|
|
||||||
// Scrollable Content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: TradeDetailContent(trade: trade),
|
||||||
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)} €'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Footer Action Bar
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:finlytic_app/core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import 'package:finlytic_app/core/widgets/status_badge.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import 'package:finlytic_app/core/network/api_client.dart';
|
import '../../asset_detail/repositories/asset_repository.dart';
|
||||||
import '../../../../features/trades/models/trade_model.dart';
|
import '../models/trade_model.dart';
|
||||||
import '../../../../features/trades/models/trade_acceptance_dto.dart';
|
import '../models/trade_acceptance_dto.dart';
|
||||||
|
import 'trade_execution_ai_plan_card.dart';
|
||||||
|
|
||||||
class TradeExecutionDialog {
|
class TradeExecutionDialog {
|
||||||
static const double _defaultPositionSize = 1000.0;
|
static const double _defaultPositionSize = 1000.0;
|
||||||
static const double _defaultLeverage = 1.0;
|
static const double _defaultLeverage = 1.0;
|
||||||
|
|
||||||
static const List<String> _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto'];
|
static const List<String> _allowedInstruments = ['Stock', 'KnockOut', 'Option', 'CFD', 'Crypto'];
|
||||||
|
|
||||||
/// Normalisiert beliebige Freitexte/Bezeichnungen auf die erlaubten Dropdown-Werte
|
|
||||||
static String _normalizeInstrumentType(String raw) {
|
static String _normalizeInstrumentType(String raw) {
|
||||||
final clean = raw.toLowerCase().trim();
|
final clean = raw.toLowerCase().trim();
|
||||||
if (clean.contains('knock') || clean.contains('zertifikat') || clean.contains('turbo')) {
|
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')) {
|
if (clean.contains('stock') || clean.contains('aktie') || clean.contains('etf')) {
|
||||||
return 'Stock';
|
return 'Stock';
|
||||||
}
|
}
|
||||||
return 'KnockOut'; // Fallback
|
return 'KnockOut';
|
||||||
}
|
}
|
||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required TradeModel trade,
|
required TradeModel trade,
|
||||||
required String defaultSymbol,
|
required String defaultSymbol,
|
||||||
bool isActive = false,
|
bool isActive = false,
|
||||||
required Function(TradeAcceptanceDto dto) onAccept,
|
required Function(TradeAcceptanceDto dto) onAccept,
|
||||||
Function(String tradeId)? onReject,
|
Function(String tradeId)? onReject,
|
||||||
}) {
|
}) {
|
||||||
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
|
final initEntry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : (trade.entryPrice > 0 ? trade.entryPrice : 100.0);
|
||||||
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
|
final initPos = trade.positionSize > 0 ? trade.positionSize : _defaultPositionSize;
|
||||||
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
|
final initLev = trade.leverageUsed > 0 ? trade.leverageUsed : _defaultLeverage;
|
||||||
|
|
||||||
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0;
|
final calcQty = (initEntry > 0 && initPos > 0) ? (initPos / initEntry) : 10.0;
|
||||||
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
|
final initQty = trade.quantity > 0 ? trade.quantity : calcQty;
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@ class TradeExecutionDialog {
|
|||||||
|
|
||||||
final derivativeIsinController = TextEditingController(text: trade.derivativeIsin);
|
final derivativeIsinController = TextEditingController(text: trade.derivativeIsin);
|
||||||
|
|
||||||
// Normalisierte Zuweisung verhindert den DropdownButton Assertion-Error
|
|
||||||
String selectedInstrumentType = _normalizeInstrumentType(
|
String selectedInstrumentType = _normalizeInstrumentType(
|
||||||
trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut',
|
trade.instrumentType.isNotEmpty ? trade.instrumentType : 'KnockOut',
|
||||||
);
|
);
|
||||||
@@ -117,11 +114,7 @@ class TradeExecutionDialog {
|
|||||||
final cleanIsin = inputIsin.trim().toUpperCase();
|
final cleanIsin = inputIsin.trim().toUpperCase();
|
||||||
if (cleanIsin.isEmpty) {
|
if (cleanIsin.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
|
||||||
content: Text('Bitte gib eine gültige Derivat/Knock-Out ISIN ein.'),
|
|
||||||
backgroundColor: Colors.amber,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,43 +122,36 @@ class TradeExecutionDialog {
|
|||||||
setModalState(() => isFetchingDerivativePrice = true);
|
setModalState(() => isFetchingDerivativePrice = true);
|
||||||
try {
|
try {
|
||||||
final apiClient = context.read<ApiClient>();
|
final apiClient = context.read<ApiClient>();
|
||||||
final res = await apiClient.get('/api/v1/assets/$cleanIsin/technicals?forceRefresh=true');
|
final assetRepo = AssetRepository(apiClient: apiClient);
|
||||||
if (res.statusCode == 200 && res.data != null) {
|
final technicals = await assetRepo.getAssetTechnical(cleanIsin, true);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fetchedPrice != null && fetchedPrice > 0) {
|
double? fetchedPrice;
|
||||||
actualEntryController.text = fetchedPrice.toStringAsFixed(2);
|
if (technicals != null) {
|
||||||
recalculateQuantity();
|
if (technicals.candles.isNotEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
fetchedPrice = technicals.candles.last.close;
|
||||||
SnackBar(
|
} else if (technicals.currentPrice != null && technicals.currentPrice! > 0) {
|
||||||
content: Text('Live-Kurs für Derivat $cleanIsin abgerufen: €${fetchedPrice.toStringAsFixed(2)}'),
|
fetchedPrice = technicals.currentPrice;
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text('Fehler beim Abrufen des Kurses für $cleanIsin: $e'), backgroundColor: AppTheme.accentRed, behavior: SnackBarBehavior.floating),
|
||||||
content: Text('Fehler beim Abrufen des Kurses für $cleanIsin via tr_GetPrice: $e'),
|
|
||||||
backgroundColor: AppTheme.accentRed,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setModalState(() => isFetchingDerivativePrice = false);
|
setModalState(() => isFetchingDerivativePrice = false);
|
||||||
@@ -185,17 +171,11 @@ class TradeExecutionDialog {
|
|||||||
selectedInstrumentType.toLowerCase().contains('option') ||
|
selectedInstrumentType.toLowerCase().contains('option') ||
|
||||||
selectedInstrumentType.toLowerCase().contains('cfd');
|
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(
|
return AlertDialog(
|
||||||
backgroundColor: AppTheme.cardSurface,
|
backgroundColor: AppTheme.cardSurface,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppTheme.glassBorder)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
side: BorderSide(color: AppTheme.glassBorder),
|
|
||||||
),
|
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22),
|
Icon(isActive ? Icons.tune : Icons.edit_note_outlined, color: AppTheme.primaryEmerald, size: 22),
|
||||||
@@ -215,142 +195,16 @@ class TradeExecutionDialog {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||||
'Trade-ID: ${trade.id} | Symbol/ISIN: ${trade.symbol.isNotEmpty ? trade.symbol : defaultSymbol}',
|
|
||||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
TradeExecutionAiPlanCard(trade: trade),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
|
const Text('Ihre Ausführungsdaten für das Depot:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Instrument-Type Dropdown mit abgesichertem Value
|
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: safeInstrumentValue,
|
initialValue: safeInstrumentValue,
|
||||||
dropdownColor: AppTheme.cardSurface,
|
dropdownColor: AppTheme.cardSurface,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Finanzinstrument Typ', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||||
labelText: 'Finanzinstrument Typ',
|
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
||||||
),
|
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 'Stock', child: Text('Aktie / ETF (Direktinvestment)', style: TextStyle(color: Colors.white, fontSize: 13))),
|
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))),
|
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))),
|
DropdownMenuItem(value: 'Crypto', child: Text('Krypto', style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
],
|
],
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) {
|
if (val != null) setModalState(() => selectedInstrumentType = val);
|
||||||
setModalState(() {
|
|
||||||
selectedInstrumentType = val;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
if (isKnockout) ...[
|
if (isKnockout) ...[
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: derivativeIsinController,
|
controller: derivativeIsinController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)', hintText: 'ISIN des Hebels eingeben...', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
|
||||||
labelText: 'Knock-Out / Derivat ISIN (z.B. DE000...)',
|
|
||||||
hintText: 'ISIN des Hebels eingeben...',
|
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: isFetchingDerivativePrice
|
onPressed: isFetchingDerivativePrice ? null : () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
|
||||||
? null
|
|
||||||
: () => fetchDerivativePrice(setModalState, derivativeIsinController.text),
|
|
||||||
icon: isFetchingDerivativePrice
|
icon: isFetchingDerivativePrice
|
||||||
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||||
: const Icon(Icons.bolt, size: 16),
|
: const Icon(Icons.bolt, size: 16),
|
||||||
label: const Text('tr_GetPrice'),
|
label: const Text('tr_GetPrice'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)),
|
||||||
backgroundColor: AppTheme.accentCyan,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
],
|
],
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -422,7 +259,6 @@ class TradeExecutionDialog {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -443,7 +279,6 @@ class TradeExecutionDialog {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -464,7 +299,6 @@ class TradeExecutionDialog {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -501,9 +335,7 @@ class TradeExecutionDialog {
|
|||||||
},
|
},
|
||||||
icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16),
|
icon: Icon(Icons.cancel, color: AppTheme.accentRed, size: 16),
|
||||||
label: Text('Trade Ablehnen', style: TextStyle(color: AppTheme.accentRed)),
|
label: Text('Trade Ablehnen', style: TextStyle(color: AppTheme.accentRed)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(side: BorderSide(color: AppTheme.accentRed)),
|
||||||
side: BorderSide(color: AppTheme.accentRed),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (!isActive && onReject != null) const SizedBox(width: 8),
|
if (!isActive && onReject != null) const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -158,6 +182,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.6"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +416,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.4.1"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -512,6 +552,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.0"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -597,6 +645,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
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:
|
sse:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -637,6 +725,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.6
|
cupertino_icons: ^1.0.6
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
flutter_svg: ^2.0.9
|
flutter_svg: ^2.0.9
|
||||||
|
cached_network_image: ^3.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -85,13 +85,12 @@ public class CalendarController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new CalendarEventResponseDto(
|
return new CalendarEventResponseDto(
|
||||||
Id: $"evt-{eventIsin}-{eventDate:yyyyMMdd}-{eventType}",
|
Id: e.Id.ToString(),
|
||||||
Symbol: string.IsNullOrWhiteSpace(ticker) ? eventIsin : ticker,
|
|
||||||
CompanyName: companyName,
|
CompanyName: companyName,
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
EventDate: eventDate,
|
EventDate: eventDate,
|
||||||
Date: eventDate.ToString("o"),
|
Date: eventDate.ToString("o"),
|
||||||
Isin: eventIsin,
|
Isin: e.Isin,
|
||||||
Ticker: ticker,
|
Ticker: ticker,
|
||||||
Description: $"{eventType} - {companyName}",
|
Description: $"{eventType} - {companyName}",
|
||||||
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
|
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
|
||||||
@@ -119,9 +118,7 @@ public class CalendarController : ControllerBase
|
|||||||
{
|
{
|
||||||
filtered = filtered.Where(e =>
|
filtered = filtered.Where(e =>
|
||||||
string.Equals(e.Isin, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(e.Isin, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(e.Ticker, activeSymbol, StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(e.Ticker, activeSymbol, StringComparison.OrdinalIgnoreCase));
|
||||||
string.Equals(e.Symbol, activeSymbol, StringComparison.OrdinalIgnoreCase)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(filtered.OrderBy(e => e.EventDate).ToList());
|
return Ok(filtered.OrderBy(e => e.EventDate).ToList());
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ namespace FinlyticCore.Dtos;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record CalendarEventResponseDto(
|
public record CalendarEventResponseDto(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("symbol")] string Symbol,
|
|
||||||
[property: JsonPropertyName("companyName")] string CompanyName,
|
[property: JsonPropertyName("companyName")] string CompanyName,
|
||||||
[property: JsonPropertyName("eventType")] string EventType,
|
[property: JsonPropertyName("eventType")] string EventType,
|
||||||
[property: JsonPropertyName("eventDate")] DateTime EventDate,
|
[property: JsonPropertyName("eventDate")] DateTime EventDate,
|
||||||
|
|||||||
@@ -477,9 +477,11 @@ public class FundamentalsDbService : IFundamentalsDbService
|
|||||||
return events.Select(e => new CorporateEventDto
|
return events.Select(e => new CorporateEventDto
|
||||||
{
|
{
|
||||||
Id = e.Id,
|
Id = e.Id,
|
||||||
|
Isin = e.AssetData.Isin,
|
||||||
Ticker = e.Ticker != null
|
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 = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) }
|
||||||
: new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" },
|
: new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" },
|
||||||
|
CompanyName = e.AssetData.Name,
|
||||||
Type = e.Type,
|
Type = e.Type,
|
||||||
Date = e.Date
|
Date = e.Date
|
||||||
}).OrderBy(e => e.Date).ToList();
|
}).OrderBy(e => e.Date).ToList();
|
||||||
@@ -504,6 +506,8 @@ public class FundamentalsDbService : IFundamentalsDbService
|
|||||||
return events.Select(e => new CorporateEventDto
|
return events.Select(e => new CorporateEventDto
|
||||||
{
|
{
|
||||||
Id = e.Id,
|
Id = e.Id,
|
||||||
|
Isin = e.AssetData.Isin,
|
||||||
|
CompanyName = e.AssetData.Name,
|
||||||
Ticker = e.Ticker != null
|
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 = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) }
|
||||||
: new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" },
|
: new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" },
|
||||||
|
|||||||
Reference in New Issue
Block a user