feat(app): clean architecture with typed repositories, DTO models and calendar event logos
This commit is contained in:
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: [
|
||||||
|
const Text(
|
||||||
|
'Administration & System',
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Finlytic Admin-Dashboard • Microservices & Benutzer',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
IconButton(
|
||||||
padding: const EdgeInsets.all(8),
|
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
|
||||||
decoration: BoxDecoration(
|
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
|
tooltip: 'Neu laden',
|
||||||
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),
|
|
||||||
Text(
|
|
||||||
'Zentrales Management für Nutzer, Mikrodienste & MQTT System-Bus',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _openCreateUser(context),
|
onPressed: () => _openCreateUser(context),
|
||||||
icon: const Icon(Icons.person_add_outlined, size: 18),
|
icon: const Icon(Icons.person_add_alt_1_rounded, size: 18),
|
||||||
label: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
|
label: const Text('Neuer Benutzer'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(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,16 +48,14 @@ 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 groupedSettings = res.data as Map<String, dynamic>;
|
|
||||||
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
|
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 {
|
||||||
@@ -56,9 +64,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
}
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
throw Exception('Failed to load settings');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
@@ -75,12 +80,8 @@ 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(
|
||||||
@@ -102,9 +103,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw Exception('Server returned status code ${res.statusCode}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -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();
|
||||||
|
if (!_initialized) {
|
||||||
|
_initialized = true;
|
||||||
|
final client = widget.apiClient ?? context.read<ApiClient>();
|
||||||
|
_repository = widget.repository ?? AdminRepository(apiClient: client);
|
||||||
_fetchSettings();
|
_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);
|
|
||||||
data.forEach((svc, items) {
|
|
||||||
if (items is List) {
|
|
||||||
_controllers.putIfAbsent(svc, () => {});
|
_controllers.putIfAbsent(svc, () => {});
|
||||||
for (var item in items) {
|
for (final s in items) {
|
||||||
final key = item['key']?.toString();
|
if (_controllers[svc]!.containsKey(s.key)) {
|
||||||
final val = item['value']?.toString();
|
_controllers[svc]![s.key]!.text = s.value;
|
||||||
if (key != null && val != null) {
|
|
||||||
if (_controllers[svc]!.containsKey(key)) {
|
|
||||||
_controllers[svc]![key]!.text = val;
|
|
||||||
} else {
|
} else {
|
||||||
_controllers[svc]![key] = TextEditingController(text: val);
|
_controllers[svc]![s.key] = TextEditingController(text: s.value);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} 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),
|
||||||
|
|
||||||
// Service Details & Config Panel
|
|
||||||
GlassContainer(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: activeService.accentColor.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: activeService.accentColor.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Icon(activeService.icon, color: activeService.accentColor, size: 22),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(activeService.displayName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
activeService.displayName,
|
const SizedBox(height: 2),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
Text(activeService.description, style: TextStyle(fontSize: 12, color: AppTheme.textMuted)),
|
||||||
),
|
|
||||||
Text(
|
|
||||||
activeService.description,
|
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
ElevatedButton.icon(
|
||||||
),
|
|
||||||
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,
|
onPressed: _isSaving ? null : _saveSettings,
|
||||||
icon: _isSaving
|
icon: _isSaving
|
||||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
|
||||||
: const Icon(Icons.save_outlined),
|
: const Icon(Icons.save_outlined, size: 16),
|
||||||
label: Text(
|
label: Text(_isSaving ? 'Speichere...' : 'Speichern'),
|
||||||
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen für ${activeService.displayName} Speichern',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryEmerald,
|
backgroundColor: AppTheme.primaryEmerald,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (_isLoading)
|
||||||
|
Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald))
|
||||||
|
else
|
||||||
|
ServiceSettingsForm(
|
||||||
|
controllers: activeControllers,
|
||||||
|
accentColor: activeService.accentColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatLabel(String key) {
|
|
||||||
return key
|
|
||||||
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
|
||||||
.replaceAll('Minutes', '(Minuten)')
|
|
||||||
.replaceAll('Seconds', '(Sekunden)')
|
|
||||||
.replaceAll('Hours', '(Stunden)')
|
|
||||||
.replaceAll('Days', '(Tage)')
|
|
||||||
.replaceAll('Limit', 'Grenzwert')
|
|
||||||
.replaceAll('Period', 'Periode')
|
|
||||||
.replaceAll('Percentage', '(%)')
|
|
||||||
.replaceAll('Multiplier', 'Multiplikator');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
|
|
||||||
|
class ServiceSettingsForm extends StatelessWidget {
|
||||||
|
final Map<String, TextEditingController> controllers;
|
||||||
|
final Color accentColor;
|
||||||
|
|
||||||
|
const ServiceSettingsForm({
|
||||||
|
super.key,
|
||||||
|
required this.controllers,
|
||||||
|
required this.accentColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _buildField(String key, TextEditingController ctrl) {
|
||||||
|
final isBool = ctrl.text == 'true' || ctrl.text == 'false';
|
||||||
|
|
||||||
|
if (isBool) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setLocal) {
|
||||||
|
return SwitchListTile(
|
||||||
|
title: Text(key, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text('Boolesche Konfigurationsflagge', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||||
|
value: ctrl.text == 'true',
|
||||||
|
activeThumbColor: accentColor,
|
||||||
|
onChanged: (newVal) {
|
||||||
|
setLocal(() {
|
||||||
|
ctrl.text = newVal ? 'true' : 'false';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(key, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (controllers.isEmpty) {
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Center(
|
||||||
|
child: Text('Keine konfigurierbaren Parameter für diesen Dienst vorhanden.', style: TextStyle(color: AppTheme.textMuted)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: controllers.entries.map((e) => _buildField(e.key, e.value)).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,14 @@ class AuthRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> changeInitialPassword(String userId, String newPassword) async {
|
||||||
|
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
|
||||||
|
'userId': userId,
|
||||||
|
'newPassword': newPassword,
|
||||||
|
});
|
||||||
|
return res.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
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) {
|
|
||||||
final list = (res.data as List)
|
|
||||||
.map((e) => DiscoveryAssetModel.fromJson(Map<String, dynamic>.from(e)))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
emit(DiscoveryState(assets: list, isLoading: false));
|
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,23 +64,14 @@ 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');
|
|
||||||
if (res.statusCode == 200 && res.data is List) {
|
|
||||||
final rawList = res.data as List;
|
|
||||||
final set = <String>{};
|
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));
|
|
||||||
final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase();
|
|
||||||
if (!dedupMap.containsKey(key)) {
|
|
||||||
dedupMap[key] = model;
|
|
||||||
}
|
|
||||||
if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase());
|
if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase());
|
||||||
if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase());
|
if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase());
|
||||||
if (model.name.isNotEmpty) set.add(model.name.toUpperCase());
|
if (model.name.isNotEmpty) set.add(model.name.toUpperCase());
|
||||||
@@ -82,12 +79,9 @@ class FavoritesCubit extends Cubit<FavoritesState> {
|
|||||||
|
|
||||||
emit(FavoritesState(
|
emit(FavoritesState(
|
||||||
favoriteIsins: set,
|
favoriteIsins: set,
|
||||||
favoriteDetails: dedupMap.values.toList(),
|
favoriteDetails: list,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
emit(state.copyWith(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) {
|
|
||||||
queryParams['date'] = _selectedDate!.toIso8601String().substring(0, 10);
|
|
||||||
}
|
|
||||||
if (_searchQuery != null && _searchQuery!.trim().isNotEmpty) {
|
|
||||||
queryParams['query'] = _searchQuery!.trim();
|
|
||||||
}
|
|
||||||
if (_selectedIsin != null && _selectedIsin!.trim().isNotEmpty) {
|
|
||||||
queryParams['isin'] = _selectedIsin!.trim();
|
|
||||||
}
|
|
||||||
if (_hasSentimentOnly) {
|
|
||||||
queryParams['hasSentiment'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final res = await widget.apiClient.get('/api/v1/news', queryParameters: queryParams);
|
|
||||||
|
|
||||||
if (res.statusCode == 200 && res.data != null && res.data is List) {
|
|
||||||
final List fetched = res.data as List;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
// Deduplicate by ID
|
final existingIds = _newsItems.map((e) => e.id).where((id) => id.isNotEmpty).toSet();
|
||||||
final existingIds = _newsItems.map((e) => e['id'] ?? e['Id']).where((id) => id != null).toSet();
|
|
||||||
for (final item in fetched) {
|
for (final item in fetched) {
|
||||||
final id = item['id'] ?? item['Id'];
|
if (item.id.isEmpty || !existingIds.contains(item.id)) {
|
||||||
if (id == null || !existingIds.contains(id)) {
|
|
||||||
_newsItems.add(item);
|
_newsItems.add(item);
|
||||||
if (id != null) existingIds.add(id);
|
if (item.id.isNotEmpty) existingIds.add(item.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,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: AppTheme.glassBorder),
|
||||||
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 700,
|
width: 650,
|
||||||
height: 620,
|
height: 600,
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
|
||||||
// Tab 1: Artikel & Volltext
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title,
|
||||||
children: [
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
Expanded(
|
maxLines: 2,
|
||||||
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.3)),
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (sourceUrl != null && sourceUrl.isNotEmpty)
|
const SizedBox(height: 4),
|
||||||
IconButton(
|
Text(
|
||||||
icon: Icon(Icons.open_in_new, color: AppTheme.primaryEmerald, size: 22),
|
'Quelle: $author • $publishedAt',
|
||||||
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),
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
listBadge,
|
listBadge,
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white70),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (matchedAssets.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
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(
|
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(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.glassSurface,
|
color: AppTheme.glassSurface,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: AppTheme.glassBorder),
|
border: Border.all(color: AppTheme.glassBorder),
|
||||||
),
|
),
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicatorColor: AppTheme.primaryEmerald,
|
||||||
|
labelColor: AppTheme.primaryEmerald,
|
||||||
|
unselectedLabelColor: AppTheme.textMuted,
|
||||||
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
tabs: const [
|
||||||
|
Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel-Inhalt'),
|
||||||
|
Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT Sentiment'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
if (sourceUrl.isNotEmpty)
|
||||||
child: Text(
|
TextButton.icon(
|
||||||
title,
|
onPressed: _openOriginalSource,
|
||||||
style: TextStyle(fontSize: 12, color: AppTheme.textMuted, fontWeight: FontWeight.bold),
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
overflow: TextOverflow.ellipsis,
|
label: const Text('Originalquelle im Browser öffnen'),
|
||||||
),
|
style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan),
|
||||||
),
|
)
|
||||||
Tooltip(
|
else
|
||||||
message: tooltipText,
|
const SizedBox.shrink(),
|
||||||
padding: const EdgeInsets.all(12),
|
ElevatedButton(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
onPressed: () => Navigator.pop(context),
|
||||||
decoration: BoxDecoration(
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
|
||||||
color: const Color(0xFF1E2130),
|
child: const Text('Schließen'),
|
||||||
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),
|
|
||||||
Text(
|
|
||||||
valueText,
|
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: accentColor),
|
|
||||||
),
|
|
||||||
if (progressValue != null) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: progressValue!.clamp(0.0, 1.0),
|
|
||||||
backgroundColor: AppTheme.glassBorder,
|
|
||||||
color: accentColor,
|
|
||||||
minHeight: 6,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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,7 +29,7 @@ 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(
|
||||||
@@ -44,7 +43,6 @@ class TradeExecutionDialog {
|
|||||||
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,14 +122,16 @@ 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;
|
double? fetchedPrice;
|
||||||
if (data['candles'] is List && (data['candles'] as List).isNotEmpty) {
|
if (technicals != null) {
|
||||||
fetchedPrice = ((data['candles'] as List).last['close'] as num?)?.toDouble();
|
if (technicals.candles.isNotEmpty) {
|
||||||
} else if (data['currentPrice'] != null) {
|
fetchedPrice = technicals.candles.last.close;
|
||||||
fetchedPrice = (data['currentPrice'] as num?)?.toDouble();
|
} else if (technicals.currentPrice != null && technicals.currentPrice! > 0) {
|
||||||
|
fetchedPrice = technicals.currentPrice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchedPrice != null && fetchedPrice > 0) {
|
if (fetchedPrice != null && fetchedPrice > 0) {
|
||||||
@@ -151,21 +146,12 @@ class TradeExecutionDialog {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text('Kein Kurs für Derivat ISIN $cleanIsin gefunden.'), backgroundColor: Colors.amber, behavior: SnackBarBehavior.floating),
|
||||||
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user