feat(App): update Finlytic Flutter app UI and blocs

This commit is contained in:
2026-08-09 21:01:46 +02:00
parent e7427b7464
commit a708d2977c
591 changed files with 1095105 additions and 0 deletions
@@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../repositories/admin_repository.dart';
import 'admin_event.dart';
import 'admin_state.dart';
export 'admin_event.dart';
export 'admin_state.dart';
class AdminBloc extends Bloc<AdminEvent, AdminState> {
final AdminRepository repository;
AdminBloc({required this.repository}) : super(AdminInitial()) {
on<FetchAdminUsers>(_onFetchUsers);
on<CreateAdminUser>(_onCreateUser);
on<UpdateAdminUser>(_onUpdateUser);
}
Future<void> _onFetchUsers(FetchAdminUsers event, Emitter<AdminState> emit) async {
emit(AdminLoading());
try {
final users = await repository.fetchUsers();
emit(AdminLoaded(users));
} catch (e) {
emit(const AdminError("Fehler beim Laden der Admin-Nutzer."));
}
}
Future<void> _onCreateUser(CreateAdminUser event, Emitter<AdminState> emit) async {
try {
await repository.createUser(event.dto);
add(FetchAdminUsers());
} catch (e) {
emit(AdminError(e.toString()));
}
}
Future<void> _onUpdateUser(UpdateAdminUser event, Emitter<AdminState> emit) async {
try {
await repository.updateUser(event.id, event.dto);
add(FetchAdminUsers());
} catch (e) {
emit(const AdminError("Nutzer konnte nicht aktualisiert werden."));
}
}
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../models/admin_create_user_request_dto.dart';
import '../models/admin_update_user_request_dto.dart';
abstract class AdminEvent extends Equatable {
const AdminEvent();
@override
List<Object?> get props => [];
}
class FetchAdminUsers extends AdminEvent {}
class CreateAdminUser extends AdminEvent {
final AdminCreateUserRequestDto dto;
const CreateAdminUser(this.dto);
@override
List<Object?> get props => [dto];
}
class UpdateAdminUser extends AdminEvent {
final String id;
final AdminUpdateUserRequestDto dto;
const UpdateAdminUser(this.id, this.dto);
@override
List<Object?> get props => [id, dto];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
abstract class AdminState extends Equatable {
const AdminState();
@override
List<Object?> get props => [];
}
class AdminInitial extends AdminState {}
class AdminLoading extends AdminState {}
class AdminLoaded extends AdminState {
final List<AdminUserModel> users;
const AdminLoaded(this.users);
@override
List<Object?> get props => [users];
}
class AdminError extends AdminState {
final String message;
const AdminError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,22 @@
class AdminCreateUserRequestDto {
final String email;
final String password;
final String fullName;
final String role;
AdminCreateUserRequestDto({
required this.email,
required this.password,
required this.fullName,
required this.role,
});
Map<String, dynamic> toJson() {
return {
'email': email,
'password': password,
'fullName': fullName,
'role': role,
};
}
}
@@ -0,0 +1,16 @@
class AdminUpdateUserRequestDto {
final String role;
final bool isActive;
AdminUpdateUserRequestDto({
required this.role,
required this.isActive,
});
Map<String, dynamic> toJson() {
return {
'role': role,
'isActive': isActive,
};
}
}
@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
class AdminUserModel extends Equatable {
final String id;
final String email;
final String fullName;
final String role;
final bool isActive;
const AdminUserModel({
required this.id,
required this.email,
required this.fullName,
required this.role,
required this.isActive,
});
factory AdminUserModel.fromJson(Map<String, dynamic> json) {
return AdminUserModel(
id: json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '',
fullName: json['fullName']?.toString() ?? json['name']?.toString() ?? '',
role: json['role']?.toString() ?? 'User',
isActive: json['isActive'] == true || json['IsActive'] == true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'fullName': fullName,
'role': role,
'isActive': isActive,
};
}
@override
List<Object?> get props => [id, email, fullName, role, isActive];
}
@@ -0,0 +1,38 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
class AdminRepository {
final ApiClient apiClient;
AdminRepository({required this.apiClient});
Future<List<AdminUserModel>> fetchUsers() async {
try {
final res = await apiClient.get('/api/v1/admin/users');
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> data = res.data;
return data.map((json) => AdminUserModel.fromJson(json)).toList();
}
return [];
} catch (e) {
print('Error fetching admin users: $e');
throw Exception('Nutzer konnten nicht geladen werden');
}
}
Future<void> createUser(AdminCreateUserRequestDto dto) async {
final res = await apiClient.post('/api/v1/admin/users', data: dto.toJson());
if (res.statusCode != 200 && res.statusCode != 201) {
throw Exception('Erstellen fehlgeschlagen');
}
}
Future<void> updateUser(String id, AdminUpdateUserRequestDto dto) async {
final res = await apiClient.put('/api/v1/admin/users/$id', data: dto.toJson());
if (res.statusCode != 200) {
throw Exception('Aktualisieren fehlgeschlagen');
}
}
}
@@ -0,0 +1,421 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../models/admin_update_user_request_dto.dart';
import '../models/admin_user_model.dart';
import '../bloc/admin_bloc.dart';
import '../repositories/admin_repository.dart';
import '../widgets/admin_kpi_header.dart';
import '../widgets/create_user_dialog.dart';
import '../widgets/edit_user_dialog.dart';
import '../widgets/system_diagnostics_widget.dart';
/// Role-Restricted Admin Panel Screen managing users, service settings, and system health.
class AdminUsersScreen extends StatelessWidget {
final ApiClient apiClient;
final SignalRService? signalRService;
const AdminUsersScreen({
super.key,
required this.apiClient,
this.signalRService,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AdminBloc(
repository: AdminRepository(apiClient: apiClient),
)..add(FetchAdminUsers()),
child: _AdminUsersScreenContent(
apiClient: apiClient,
signalRService: signalRService,
),
);
}
}
class _AdminUsersScreenContent extends StatefulWidget {
final ApiClient apiClient;
final SignalRService? signalRService;
const _AdminUsersScreenContent({
required this.apiClient,
this.signalRService,
});
@override
State<_AdminUsersScreenContent> createState() => _AdminUsersScreenContentState();
}
class _AdminUsersScreenContentState extends State<_AdminUsersScreenContent> with SingleTickerProviderStateMixin {
late TabController _tabController;
String _searchQuery = '';
String _roleFilter = 'Alle';
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _openCreateUser(BuildContext context) async {
final res = await showDialog(context: context, builder: (_) => const CreateUserDialog());
if (res != null && context.mounted) {
context.read<AdminBloc>().add(CreateAdminUser(res));
}
}
void _openEditUser(BuildContext context, AdminUserModel user) async {
final res = await showDialog(context: context, builder: (_) => EditUserDialog(user: user));
if (res != null && context.mounted) {
context.read<AdminBloc>().add(UpdateAdminUser(user.id, res as AdminUpdateUserRequestDto));
}
}
void _toggleUserActiveStatus(BuildContext context, AdminUserModel user, bool newActive) {
final dto = AdminUpdateUserRequestDto(role: user.role, isActive: newActive);
context.read<AdminBloc>().add(UpdateAdminUser(user.id, dto));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: BlocBuilder<AdminBloc, AdminState>(
builder: (context, state) {
final users = state is AdminLoaded ? state.users : <AdminUserModel>[];
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top Header Ribbon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Icon(Icons.admin_panel_settings_rounded, color: AppTheme.primaryEmerald, size: 22),
),
const SizedBox(width: 12),
const Text(
'Admin Control Panel',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: -0.5),
),
],
),
const SizedBox(height: 4),
Text(
'Zentrales Management für Nutzer, Mikrodienste & MQTT System-Bus',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
ElevatedButton.icon(
onPressed: () => _openCreateUser(context),
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
const SizedBox(height: 16),
// KPI Header Metrics
AdminKpiHeader(
users: users,
signalRService: widget.signalRService,
),
const SizedBox(height: 16),
// Tab Selector Ribbon
Container(
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.glassBorder),
),
child: TabBar(
controller: _tabController,
indicatorColor: AppTheme.primaryEmerald,
indicatorSize: TabBarIndicatorSize.tab,
labelColor: AppTheme.primaryEmerald,
unselectedLabelColor: AppTheme.textMuted,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
indicator: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
),
tabs: const [
Tab(icon: Icon(Icons.people_alt_outlined, size: 18), text: 'Nutzerverwaltung'),
Tab(icon: Icon(Icons.monitor_heart_outlined, size: 18), text: 'System-Diagnose & MQTT'),
],
),
),
const SizedBox(height: 16),
// Tab Content View
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: User Management with Filter & Actions
_buildUserManagementTab(context, state, users),
// Tab 2: System Diagnostics & Microservices Health
SystemDiagnosticsWidget(
signalRService: widget.signalRService,
),
],
),
),
],
),
);
},
),
);
}
Widget _buildUserManagementTab(BuildContext context, AdminState state, List<AdminUserModel> allUsers) {
if (state is AdminLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AdminError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
const SizedBox(height: 12),
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<AdminBloc>().add(FetchAdminUsers()),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
child: const Text('Erneut versuchen'),
),
],
),
),
);
}
// Apply Search Query & Role Filter
final filteredUsers = allUsers.where((u) {
final matchesSearch = u.fullName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
u.email.toLowerCase().contains(_searchQuery.toLowerCase());
final matchesRole = _roleFilter == 'Alle' || u.role.toLowerCase() == _roleFilter.toLowerCase();
return matchesSearch && matchesRole;
}).toList();
return Column(
children: [
// Filter Bar (Search Field & Role Filter Chips)
Row(
children: [
Expanded(
child: TextField(
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Benutzer nach Name oder E-Mail suchen...',
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () => setState(() => _searchQuery = ''),
)
: null,
),
),
),
const SizedBox(width: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: ['Alle', 'Admin', 'Premium', 'User'].map((role) {
final isSelected = _roleFilter == role;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(role),
selected: isSelected,
selectedColor: AppTheme.primaryEmerald,
backgroundColor: AppTheme.glassSurface,
labelStyle: TextStyle(
color: isSelected ? Colors.black : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
side: BorderSide(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder),
onSelected: (val) {
if (val) setState(() => _roleFilter = role);
},
),
);
}).toList(),
),
),
],
),
const SizedBox(height: 14),
// User Cards List
Expanded(
child: filteredUsers.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_search_outlined, size: 48, color: AppTheme.textMuted),
const SizedBox(height: 12),
Text(
'Keine passenden Benutzer gefunden.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
),
],
),
)
: ListView.builder(
itemCount: filteredUsers.length,
itemBuilder: (context, index) {
final u = filteredUsers[index];
final String role = u.role;
final bool isActive = u.isActive;
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
final String initials = _getInitials(u.fullName.isNotEmpty ? u.fullName : u.email);
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
child: Row(
children: [
// Avatar Initials Circle
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: roleColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: roleColor, width: 1.5),
),
child: Text(
initials,
style: TextStyle(fontWeight: FontWeight.bold, color: roleColor, fontSize: 14),
),
),
const SizedBox(width: 14),
// User Name & Email
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
u.fullName.isNotEmpty ? u.fullName : u.email,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(width: 8),
StatusBadge(label: role, color: roleColor),
],
),
const SizedBox(height: 2),
Text(
u.email,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
// Active/Inactive Quick Switch Toggle
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
isActive ? 'Aktiv' : 'Gesperrt',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isActive ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
),
],
),
const SizedBox(width: 8),
Switch(
value: isActive,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: (val) => _toggleUserActiveStatus(context, u, val),
),
const SizedBox(width: 8),
IconButton.filledTonal(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: 'Benutzer Bearbeiten',
onPressed: () => _openEditUser(context, u),
),
],
),
],
),
);
},
),
),
],
);
}
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();
}
}
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
class ServiceDetailScreen extends StatefulWidget {
final String serviceName;
final ApiClient apiClient;
const ServiceDetailScreen({super.key, required this.serviceName, required this.apiClient});
@override
State<ServiceDetailScreen> createState() => _ServiceDetailScreenState();
}
class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
bool _isLoading = true;
bool _isSaving = false;
String _error = '';
List<dynamic> _settings = [];
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
_fetchServiceDetails();
}
@override
void dispose() {
for (var ctrl in _controllers.values) {
ctrl.dispose();
}
super.dispose();
}
Future<void> _fetchServiceDetails() async {
try {
final res = await widget.apiClient.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data != null) {
final groupedSettings = res.data as Map<String, dynamic>;
final serviceSettings = groupedSettings[widget.serviceName] ?? [];
setState(() {
_settings = serviceSettings;
for (var s in _settings) {
final key = s['key']?.toString() ?? '';
final val = s['value']?.toString() ?? '';
if (!_controllers.containsKey(key)) {
_controllers[key] = TextEditingController(text: val);
} else {
_controllers[key]!.text = val;
}
}
_isLoading = false;
});
} else {
throw Exception('Failed to load settings');
}
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final payload = <String, String>{};
_controllers.forEach((k, v) {
payload[k] = v.text;
});
final res = await widget.apiClient.put(
'/api/v1/admin/settings/${widget.serviceName}',
data: payload,
);
if (res.statusCode == 200 || res.statusCode == 204) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für ${widget.serviceName} gespeichert & via MQTT synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} else {
throw Exception('Server returned status code ${res.statusCode}');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $e'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
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');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.darkBackground,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text('${widget.serviceName} Details', style: const TextStyle(fontWeight: FontWeight.bold)),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error.isNotEmpty
? Center(child: Text(_error, style: const TextStyle(color: Colors.red)))
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Einstellungen & Konfiguration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
StatusBadge(label: 'MQTT Sync', color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
if (_settings.isEmpty)
const Text('Keine spezifischen Einstellungen gefunden.')
else
..._settings.map((s) {
final key = s['key']?.toString() ?? '';
final desc = s['description']?.toString() ?? '';
final controller = _controllers[key];
if (controller == null) return const SizedBox.shrink();
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(key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
value: boolVal,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: (val) => setState(() => controller.text = val.toString()),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextField(
controller: controller,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: _formatLabel(key),
helperText: desc.isNotEmpty ? desc : null,
helperMaxLines: 2,
prefixIcon: Icon(Icons.tune_outlined, size: 18, color: AppTheme.primaryEmerald),
),
),
);
}).toList(),
const SizedBox(height: 12),
if (_settings.isNotEmpty)
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined),
label: Text(
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen Speichern',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
const SizedBox(height: 24),
GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Statistiken', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
const Text('Live-Statistiken werden noch implementiert...', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.white54)),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,224 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_user_model.dart';
/// Modern KPI Header Card Row for Admin Panel Dashboard Overview.
/// Displays real-time live metrics for Users, Administrators, Microservices Health, and MQTT Bus.
/// Continuously updates live EXCLUSIVELY over SignalR WebSockets (`/hubs/health`).
class AdminKpiHeader extends StatefulWidget {
final List<AdminUserModel> users;
final SignalRService? signalRService;
const AdminKpiHeader({
super.key,
required this.users,
this.signalRService,
});
@override
State<AdminKpiHeader> createState() => _AdminKpiHeaderState();
}
class _AdminKpiHeaderState extends State<AdminKpiHeader> {
int? _totalServices;
int? _onlineServices;
bool _mqttConnected = false;
StreamSubscription<List<Map<String, dynamic>>>? _healthSub;
@override
void initState() {
super.initState();
if (widget.signalRService != null) {
_healthSub = widget.signalRService!.healthStream.listen((data) {
if (mounted && data.isNotEmpty) {
final int total = data.length;
final int online = data.where((item) => item['status']?.toString().toLowerCase() == 'online').length;
setState(() {
_totalServices = total;
_onlineServices = online;
_mqttConnected = online > 0;
});
}
});
}
}
@override
void dispose() {
_healthSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final totalUsers = widget.users.length;
final activeUsers = widget.users.where((u) => u.isActive).length;
final adminCount = widget.users.where((u) => u.role.toLowerCase() == 'admin').length;
final isMobile = MediaQuery.of(context).size.width < 700;
final String servicesValue = _totalServices != null ? '$_onlineServices / $_totalServices' : 'SignalR...';
final String servicesSubtitle = (_onlineServices == _totalServices && _totalServices != null && _totalServices! > 0
? 'Alle Dienste online (WebSocket)'
: (_onlineServices != null ? '$_onlineServices von $_totalServices erreichbar' : 'Verbinde WebSocket...'));
final Color servicesColor = (_onlineServices == _totalServices && _totalServices != null && _totalServices! > 0)
? AppTheme.primaryEmerald
: (_onlineServices != null && _onlineServices! > 0 ? AppTheme.accentCyan : AppTheme.accentRed);
final cards = [
_KpiCard(
title: 'Benutzer Gesamt',
value: totalUsers.toString(),
subtitle: '$activeUsers aktiv • ${totalUsers - activeUsers} gesperrt',
icon: Icons.people_alt_rounded,
accentColor: AppTheme.primaryEmerald,
),
_KpiCard(
title: 'Administratoren',
value: adminCount.toString(),
subtitle: 'Vollzugriff auf System',
icon: Icons.admin_panel_settings_rounded,
accentColor: const Color(0xFFA855F7), // Purple accent
),
_KpiCard(
title: 'Mikrodienste',
value: servicesValue,
subtitle: servicesSubtitle,
icon: Icons.dns_rounded,
accentColor: servicesColor,
showPulse: _onlineServices != null && _onlineServices! > 0,
),
_KpiCard(
title: 'MQTT Live-Bus',
value: _mqttConnected ? 'Aktiv' : 'Offline',
subtitle: _mqttConnected ? 'SignalR & RPC Bereit' : 'Warte auf WebSocket',
icon: Icons.sensors_rounded,
accentColor: _mqttConnected ? const Color(0xFF10B981) : AppTheme.accentRed,
),
];
if (isMobile) {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.5,
children: cards,
);
}
return Row(
children: cards
.map((card) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: card,
),
))
.toList(),
);
}
}
class _KpiCard extends StatelessWidget {
final String title;
final String value;
final String subtitle;
final IconData icon;
final Color accentColor;
final bool showPulse;
const _KpiCard({
required this.title,
required this.value,
required this.subtitle,
required this.icon,
required this.accentColor,
this.showPulse = false,
});
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textMuted,
),
),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: accentColor.withValues(alpha: 0.3)),
),
child: Icon(icon, size: 16, color: accentColor),
),
],
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
letterSpacing: -0.5,
),
),
if (showPulse) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppTheme.primaryEmerald,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppTheme.primaryEmerald.withValues(alpha: 0.6),
blurRadius: 6,
spreadRadius: 2,
),
],
),
),
],
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_create_user_request_dto.dart';
/// Modal dialog for creating new user accounts by Admin.
class CreateUserDialog extends StatefulWidget {
const CreateUserDialog({super.key});
@override
State<CreateUserDialog> createState() => _CreateUserDialogState();
}
class _CreateUserDialogState extends State<CreateUserDialog> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _nameController = TextEditingController();
String _selectedRole = 'User';
bool _obscurePassword = true;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppTheme.cardSurface,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: GlassContainer(
borderRadius: 20,
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Icon(Icons.person_add_outlined, color: AppTheme.primaryEmerald, size: 20),
),
const SizedBox(width: 12),
const Text(
'Neuen Benutzer Anlegen',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Vollständiger Name',
prefixIcon: Icon(Icons.badge_outlined, size: 20),
),
),
const SizedBox(height: 12),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail Adresse',
prefixIcon: Icon(Icons.email_outlined, size: 20),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Passwort',
prefixIcon: const Icon(Icons.lock_outline, size: 20),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 20),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
),
const SizedBox(height: 16),
Text(
'Benutzerrolle Zuweisen',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Row(
children: [
_RoleChip(
role: 'User',
label: 'User',
isSelected: _selectedRole == 'User',
onTap: () => setState(() => _selectedRole = 'User'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Premium',
label: 'Premium',
isSelected: _selectedRole == 'Premium',
onTap: () => setState(() => _selectedRole = 'Premium'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Admin',
label: 'Admin',
isSelected: _selectedRole == 'Admin',
onTap: () => setState(() => _selectedRole = 'Admin'),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
if (_emailController.text.trim().isEmpty) return;
Navigator.pop(context, AdminCreateUserRequestDto(
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
fullName: _nameController.text.trim(),
role: _selectedRole,
));
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Nutzer Anlegen', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
],
),
),
),
);
}
}
class _RoleChip extends StatelessWidget {
final String role;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _RoleChip({
required this.role,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? roleColor.withValues(alpha: 0.2) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? roleColor : AppTheme.glassBorder,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
),
),
),
),
);
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../models/admin_user_model.dart';
import '../models/admin_update_user_request_dto.dart';
/// Modal dialog for editing user role or active status by Admin.
class EditUserDialog extends StatefulWidget {
final AdminUserModel user;
const EditUserDialog({super.key, required this.user});
@override
State<EditUserDialog> createState() => _EditUserDialogState();
}
class _EditUserDialogState extends State<EditUserDialog> {
late String _role;
late bool _isActive;
@override
void initState() {
super.initState();
_role = widget.user.role.isNotEmpty ? widget.user.role : 'User';
_isActive = widget.user.isActive;
}
@override
Widget build(BuildContext context) {
final String email = widget.user.email;
final String name = widget.user.fullName;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: GlassContainer(
borderRadius: 20,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.3)),
),
child: Icon(Icons.manage_accounts_outlined, color: AppTheme.accentCyan, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name.isNotEmpty ? name : 'Benutzer Bearbeiten',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
Text(
email,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
Text(
'Rolle Ändern',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Row(
children: [
_RoleChip(
role: 'User',
label: 'User',
isSelected: _role == 'User',
onTap: () => setState(() => _role = 'User'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Premium',
label: 'Premium',
isSelected: _role == 'Premium',
onTap: () => setState(() => _role = 'Premium'),
),
const SizedBox(width: 8),
_RoleChip(
role: 'Admin',
label: 'Admin',
isSelected: _role == 'Admin',
onTap: () => setState(() => _role = 'Admin'),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Konto Status', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: Text(_isActive ? 'Aktiv (Zugriff gewährt)' : 'Gesperrt (Zugriff verweigert)',
style: TextStyle(fontSize: 12, color: _isActive ? AppTheme.primaryEmerald : AppTheme.accentRed)),
value: _isActive,
activeThumbColor: AppTheme.primaryEmerald,
onChanged: (val) => setState(() => _isActive = val),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => Navigator.pop(context, AdminUpdateUserRequestDto(role: _role, isActive: _isActive)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Speichern', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
],
),
),
);
}
}
class _RoleChip extends StatelessWidget {
final String role;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _RoleChip({
required this.role,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final Color roleColor = role == 'Admin'
? const Color(0xFFA855F7)
: role == 'Premium'
? AppTheme.primaryEmerald
: AppTheme.accentCyan;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? roleColor.withValues(alpha: 0.2) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? roleColor : AppTheme.glassBorder,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
),
),
),
),
);
}
}
@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
/// Service Metadata Info used for Admin Config Navigation
class ServiceConfigMeta {
final String key;
final String displayName;
final String description;
final IconData icon;
final Color accentColor;
const ServiceConfigMeta({
required this.key,
required this.displayName,
required this.description,
required this.icon,
required this.accentColor,
});
}
/// Centralized Service Configuration Management Widget for Admin Panel.
class PipelineSettingsWidget extends StatefulWidget {
final ApiClient? apiClient;
const PipelineSettingsWidget({super.key, this.apiClient});
@override
State<PipelineSettingsWidget> createState() => _PipelineSettingsWidgetState();
}
class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
String _selectedServiceKey = 'FinlyticAssets';
bool _isLoading = false;
bool _isSaving = false;
static const List<ServiceConfigMeta> _services = [
ServiceConfigMeta(
key: 'FinlyticAssets',
displayName: 'Asset Katalog & Logos',
description: 'Verwaltet ISIN Asset-Stammdaten & Trade Republic Logo Fetcher',
icon: Icons.inventory_2_outlined,
accentColor: Color(0xFF00E5FF),
),
ServiceConfigMeta(
key: 'FinlyticNews',
displayName: 'News Scraper & AI',
description: 'RSS Web Scraper Intervall & Entwurf-Retention',
icon: Icons.newspaper_outlined,
accentColor: Color(0xFF3B82F6),
),
ServiceConfigMeta(
key: 'FinlyticTechnicalAnalysis',
displayName: 'Technische Analyse',
description: 'EMA/SMA Perioden, RSI Grenzwerte & Supertrend Multiplikator',
icon: Icons.show_chart_outlined,
accentColor: Color(0xFF8B5CF6),
),
ServiceConfigMeta(
key: 'FinlyticSentiment',
displayName: 'Sentiment NLP',
description: 'NLP Vertrauens-Schwellenwerte & Text-Batching',
icon: Icons.psychology_outlined,
accentColor: Color(0xFFEC4899),
),
ServiceConfigMeta(
key: 'FinlyticAnalyzer',
displayName: 'Analyzer Signal Engine',
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
icon: Icons.analytics_outlined,
accentColor: Color(0xFFF59E0B),
),
ServiceConfigMeta(
key: 'FinlyticTrades',
displayName: 'Trade Manager',
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
icon: Icons.candlestick_chart_outlined,
accentColor: Color(0xFF10B981),
),
ServiceConfigMeta(
key: 'FinlyticFundamentals',
displayName: 'Fundamentaldaten',
description: 'Cache TTL Dauer & Yahoo Finance Fallback',
icon: Icons.corporate_fare_outlined,
accentColor: Color(0xFF06B6D4),
),
];
final Map<String, Map<String, TextEditingController>> _controllers = {
'FinlyticAssets': {
'TradeRepublicMaxRequestPageSize': TextEditingController(text: '100'),
'AssetUpdateTypeDelay': TextEditingController(text: '0'),
'BatchAssetUpdateDelay': TextEditingController(text: '5'),
},
'FinlyticNews': {
'ScrapingIntervalMinutes': TextEditingController(text: '15'),
'PollingFrequencyMinutes': TextEditingController(text: '15'),
'ArticleRetentionDays': TextEditingController(text: '90'),
'DefaultPageSize': TextEditingController(text: '20'),
},
'FinlyticTechnicalAnalysis': {
'EmaShortPeriod': TextEditingController(text: '20'),
'SmaMediumPeriod': TextEditingController(text: '50'),
'SmaLongPeriod': TextEditingController(text: '200'),
'RsiOverboughtLimit': TextEditingController(text: '70'),
'RsiOversoldLimit': TextEditingController(text: '30'),
'SupertrendMultiplier': TextEditingController(text: '3.0'),
},
'FinlyticSentiment': {
'MinConfidenceScore': TextEditingController(text: '0.70'),
'MaxBatchSize': TextEditingController(text: '50'),
},
'FinlyticAnalyzer': {
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
'MinSignalScore': TextEditingController(text: '75'),
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
},
'FinlyticTrades': {
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
'MaxOpenPositions': TextEditingController(text: '5'),
},
'FinlyticFundamentals': {
'CacheTtlHours': TextEditingController(text: '24'),
'EnableYahooFallback': TextEditingController(text: 'true'),
},
};
@override
void initState() {
super.initState();
_fetchSettings();
}
Future<void> _fetchSettings() async {
if (widget.apiClient == null) return;
setState(() => _isLoading = true);
try {
final res = await widget.apiClient!.get('/api/v1/admin/settings');
if (res.statusCode == 200 && res.data is Map) {
final Map<String, dynamic> data = Map<String, dynamic>.from(res.data);
data.forEach((svc, items) {
if (items is List) {
_controllers.putIfAbsent(svc, () => {});
for (var item in items) {
final key = item['key']?.toString();
final val = item['value']?.toString();
if (key != null && val != null) {
if (_controllers[svc]!.containsKey(key)) {
_controllers[svc]![key]!.text = val;
} else {
_controllers[svc]![key] = TextEditingController(text: val);
}
}
}
}
});
}
} catch (_) {
// Retain standard default in-memory values
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final currentSvcControllers = _controllers[_selectedServiceKey] ?? {};
final payload = <String, String>{};
currentSvcControllers.forEach((k, v) {
payload[k] = v.text;
});
if (widget.apiClient != null) {
await widget.apiClient!.put('/api/v1/admin/settings/$_selectedServiceKey', data: payload);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.black),
const SizedBox(width: 8),
Expanded(
child: Text(
'Einstellungen für $_selectedServiceKey gespeichert & via MQTT synchronisiert.',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} catch (ex) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fehler beim Speichern: $ex'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final activeService = _services.firstWhere((s) => s.key == _selectedServiceKey, orElse: () => _services.first);
final activeControllers = _controllers[_selectedServiceKey] ?? {};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Service Selection Ribbon
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _services.map((svc) {
final isSelected = svc.key == _selectedServiceKey;
return Padding(
padding: const EdgeInsets.only(right: 8, bottom: 12),
child: InkWell(
onTap: () => setState(() => _selectedServiceKey = svc.key),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? svc.accentColor.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? svc.accentColor : AppTheme.glassBorder,
width: isSelected ? 1.5 : 1,
),
),
child: Row(
children: [
Icon(svc.icon, size: 18, color: isSelected ? svc.accentColor : AppTheme.textMuted),
const SizedBox(width: 8),
Text(
svc.displayName,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.textPrimary : AppTheme.textSecondary,
),
),
],
),
),
),
);
}).toList(),
),
),
const SizedBox(height: 8),
// Service Details & Config Panel
GlassContainer(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: activeService.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: activeService.accentColor.withValues(alpha: 0.3)),
),
child: Icon(activeService.icon, color: activeService.accentColor, size: 22),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activeService.displayName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
activeService.description,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
],
),
if (_isLoading)
SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryEmerald))
else
StatusBadge(label: 'MQTT Sync', color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 16),
// Parameter Input List
...activeControllers.entries.map((entry) {
final keyName = entry.key;
final controller = entry.value;
final isBoolean = controller.text.toLowerCase() == 'true' || controller.text.toLowerCase() == 'false';
if (isBoolean) {
final boolVal = controller.text.toLowerCase() == 'true';
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(_formatLabel(keyName), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
//subtitle: Text('Schlüssel: $keyName', style: TextStyle(fontSize: 11, color: AppTheme.textMuted)),
value: boolVal,
activeThumbColor: activeService.accentColor,
onChanged: (val) => setState(() => controller.text = val.toString()),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: _formatLabel(keyName),
//helperText: 'Schlüssel: $keyName',
prefixIcon: Icon(Icons.tune_outlined, size: 18, color: activeService.accentColor),
),
),
),
/*if (isNumeric) ...[
const SizedBox(width: 8),
IconButton.filledTonal(
icon: const Icon(Icons.remove, size: 18),
onPressed: () => setState(() => _adjustNumericValue(controller, -1.0, isDouble: isDouble)),
),
IconButton.filledTonal(
icon: const Icon(Icons.add, size: 18),
onPressed: () => setState(() => _adjustNumericValue(controller, 1.0, isDouble: isDouble)),
),
],*/
],
),
);
}),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.save_outlined),
label: Text(
_isSaving ? 'Speichere & Sende via MQTT...' : 'Einstellungen für ${activeService.displayName} Speichern',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
],
);
}
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,257 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../views/service_detail_screen.dart';
/// Real-Time System Diagnostics Widget driven EXCLUSIVELY over SignalR WebSockets (`/hubs/health`).
/// ZERO REST HTTP API calls are performed.
class SystemDiagnosticsWidget extends StatefulWidget {
final SignalRService? signalRService;
const SystemDiagnosticsWidget({
super.key,
this.signalRService,
});
@override
State<SystemDiagnosticsWidget> createState() => _SystemDiagnosticsWidgetState();
}
class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
List<Map<String, dynamic>> _serviceStatuses = [];
StreamSubscription<List<Map<String, dynamic>>>? _healthSub;
@override
void initState() {
super.initState();
if (widget.signalRService != null) {
_healthSub = widget.signalRService!.healthStream.listen((data) {
if (mounted) {
setState(() {
_serviceStatuses = data;
});
}
});
}
}
@override
void dispose() {
_healthSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final int totalCount = _serviceStatuses.length;
final int onlineCount = _serviceStatuses.where((s) => s['status']?.toString().toLowerCase() == 'online').length;
final bool isWsConnected = widget.signalRService?.isConnected ?? false;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// WebSocket Status Banner
GlassContainer(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Icon(Icons.sensors_rounded, color: AppTheme.primaryEmerald, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'SignalR WebSocket Live-Diagnose',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: isWsConnected ? AppTheme.primaryEmerald : AppTheme.accentRed,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (isWsConnected ? AppTheme.primaryEmerald : AppTheme.accentRed).withValues(alpha: 0.8),
blurRadius: 6,
spreadRadius: 2,
),
],
),
),
],
),
const SizedBox(height: 2),
Text(
'100% über SignalR WebSockets (/hubs/health). Keine HTTP API Anfragen.',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: (onlineCount == totalCount && totalCount > 0 ? AppTheme.primaryEmerald : AppTheme.accentCyan).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: (onlineCount == totalCount && totalCount > 0 ? AppTheme.primaryEmerald : AppTheme.accentCyan).withValues(alpha: 0.4)),
),
child: Text(
totalCount > 0 ? '$onlineCount / $totalCount Online' : 'Verbinde WebSocket...',
style: TextStyle(
color: onlineCount == totalCount && totalCount > 0 ? AppTheme.primaryEmerald : AppTheme.accentCyan,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'Echtzeit Dienststatus (SignalR Push)',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 12),
if (_serviceStatuses.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(
children: [
CircularProgressIndicator(color: AppTheme.primaryEmerald),
const SizedBox(height: 16),
Text('Warte auf SignalR WebSocket Daten von /hubs/health...', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _serviceStatuses.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).size.width > 900 ? 2 : 1,
childAspectRatio: 2.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemBuilder: (context, index) {
final svc = _serviceStatuses[index];
final String name = svc['name']?.toString() ?? 'Unbekannt';
final String type = svc['type']?.toString() ?? '';
final String status = svc['status']?.toString() ?? 'Offline';
final bool isOnline = status.toLowerCase() == 'online';
final String portInfo = svc['port']?.toString() ?? 'MQTT Only';
final String db = svc['db']?.toString() ?? 'PostgreSQL';
return InkWell(
onTap: () {
final apiClient = context.read<ApiClient>();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ServiceDetailScreen(serviceName: name, apiClient: apiClient),
),
);
},
borderRadius: BorderRadius.circular(20),
child: GlassContainer(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
size: 18,
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
const SizedBox(width: 8),
Text(
name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
],
),
StatusBadge(
label: status,
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
],
),
Text(
type,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
overflow: TextOverflow.ellipsis,
),
const Divider(height: 10, color: Colors.white10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.storage_outlined, size: 12, color: AppTheme.textMuted),
const SizedBox(width: 4),
Text(
db,
style: TextStyle(fontSize: 11, color: AppTheme.textMuted),
),
],
),
Row(
children: [
Icon(name == 'FinlyticBackend' ? Icons.language_outlined : Icons.cable_outlined,
size: 12, color: AppTheme.accentCyan),
const SizedBox(width: 4),
Text(
portInfo,
style: TextStyle(
fontSize: 11,
color: name == 'FinlyticBackend' ? AppTheme.primaryEmerald : AppTheme.accentCyan,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
],
),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/asset_detail/repositories/asset_repository.dart';
import 'asset_detail_event.dart';
import 'asset_detail_state.dart';
class AssetDetailBloc extends Bloc<AssetDetailEvent, AssetDetailState> {
final AssetRepository repository;
AssetDetailBloc({required this.repository}) : super(AssetDetailInitial()) {
on<LoadAssetData>(_onLoadAssetData);
on<ForceRefreshAssetData>(_onForceRefreshAssetData);
}
Future<void> _onLoadAssetData(LoadAssetData event, Emitter<AssetDetailState> emit) async {
emit(AssetDetailLoading());
try {
final results = await Future.wait([
repository.getFundamentalData(event.symbol),
repository.getTechnicalAnalysis(event.symbol),
]);
emit(AssetDetailLoaded(
fundamentalData: results[0] as dynamic,
technicalAnalysis: results[1] as dynamic,
));
} catch (e) {
emit(AssetDetailError("Fehler beim Laden der Asset-Daten."));
}
}
Future<void> _onForceRefreshAssetData(ForceRefreshAssetData event, Emitter<AssetDetailState> emit) async {
try {
await repository.forceRefreshFundamentalData(event.symbol);
// Optional: re-load after a delay, or rely on MQTT/SignalR to push the new data.
} catch (e) {
// ignore
}
}
}
@@ -0,0 +1,26 @@
import 'package:equatable/equatable.dart';
abstract class AssetDetailEvent extends Equatable {
const AssetDetailEvent();
@override
List<Object?> get props => [];
}
class LoadAssetData extends AssetDetailEvent {
final String symbol;
const LoadAssetData(this.symbol);
@override
List<Object?> get props => [symbol];
}
class ForceRefreshAssetData extends AssetDetailEvent {
final String symbol;
const ForceRefreshAssetData(this.symbol);
@override
List<Object?> get props => [symbol];
}
@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
abstract class AssetDetailState extends Equatable {
const AssetDetailState();
@override
List<Object?> get props => [];
}
class AssetDetailInitial extends AssetDetailState {}
class AssetDetailLoading extends AssetDetailState {}
class AssetDetailLoaded extends AssetDetailState {
final FundamentalDataModel? fundamentalData;
final TechnicalAnalysisModel? technicalAnalysis;
const AssetDetailLoaded({this.fundamentalData, this.technicalAnalysis});
@override
List<Object?> get props => [fundamentalData, technicalAnalysis];
}
class AssetDetailError extends AssetDetailState {
final String message;
const AssetDetailError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,19 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_fundamentals_event.dart';
import 'asset_fundamentals_state.dart';
import '../../repositories/asset_repository.dart';
class AssetFundamentalsBloc extends Bloc<AssetFundamentalsEvent, AssetFundamentalsState> {
final AssetRepository repository;
AssetFundamentalsBloc({required this.repository}) : super(AssetFundamentalsInitial()) {
on<LoadAssetFundamentals>((event, emit) async {
emit(AssetFundamentalsLoading());
try {
final data = await repository.getAssetFundamentals(event.isin, event.forceRefresh, ticker: event.ticker);
emit(AssetFundamentalsLoaded(data));
} catch (e) {
emit(AssetFundamentalsError(e.toString()));
}
});
}
}
@@ -0,0 +1,7 @@
abstract class AssetFundamentalsEvent {}
class LoadAssetFundamentals extends AssetFundamentalsEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetFundamentals(this.isin, {this.forceRefresh = false, this.ticker});
}
@@ -0,0 +1,13 @@
import '../../models/fundamental_data_model.dart';
abstract class AssetFundamentalsState {}
class AssetFundamentalsInitial extends AssetFundamentalsState {}
class AssetFundamentalsLoading extends AssetFundamentalsState {}
class AssetFundamentalsLoaded extends AssetFundamentalsState {
final FundamentalDataModel? data;
AssetFundamentalsLoaded(this.data);
}
class AssetFundamentalsError extends AssetFundamentalsState {
final String message;
AssetFundamentalsError(this.message);
}
@@ -0,0 +1,20 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_header_event.dart';
import 'asset_header_state.dart';
import '../../repositories/asset_repository.dart';
class AssetHeaderBloc extends Bloc<AssetHeaderEvent, AssetHeaderState> {
final AssetRepository repository;
AssetHeaderBloc({required this.repository}) : super(AssetHeaderInitial()) {
on<LoadAssetHeader>((event, emit) async {
final prevData = state is AssetHeaderLoaded ? (state as AssetHeaderLoaded).data : (state is AssetHeaderLoading ? (state as AssetHeaderLoading).previousData : null);
emit(AssetHeaderLoading(previousData: prevData));
try {
final data = await repository.getAssetHeader(event.isin, exchange: event.exchange, ticker: event.ticker);
emit(AssetHeaderLoaded(data));
} catch (e) {
emit(AssetHeaderError(e.toString()));
}
});
}
}
@@ -0,0 +1,8 @@
abstract class AssetHeaderEvent {}
class LoadAssetHeader extends AssetHeaderEvent {
final String isin;
final bool forceRefresh;
final String? exchange;
final String? ticker;
LoadAssetHeader(this.isin, {this.forceRefresh = false, this.exchange, this.ticker});
}
@@ -0,0 +1,16 @@
import '../../models/asset_model.dart';
abstract class AssetHeaderState {}
class AssetHeaderInitial extends AssetHeaderState {}
class AssetHeaderLoading extends AssetHeaderState {
final AssetModel? previousData;
AssetHeaderLoading({this.previousData});
}
class AssetHeaderLoaded extends AssetHeaderState {
final AssetModel? data;
AssetHeaderLoaded(this.data);
}
class AssetHeaderError extends AssetHeaderState {
final String message;
AssetHeaderError(this.message);
}
@@ -0,0 +1,19 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_technical_event.dart';
import 'asset_technical_state.dart';
import '../../repositories/asset_repository.dart';
class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState> {
final AssetRepository repository;
AssetTechnicalBloc({required this.repository}) : super(AssetTechnicalInitial()) {
on<LoadAssetTechnical>((event, emit) async {
emit(AssetTechnicalLoading());
try {
final data = await repository.getAssetTechnical(event.isin, event.forceRefresh, ticker: event.ticker);
emit(AssetTechnicalLoaded(data));
} catch (e) {
emit(AssetTechnicalError(e.toString()));
}
});
}
}
@@ -0,0 +1,7 @@
abstract class AssetTechnicalEvent {}
class LoadAssetTechnical extends AssetTechnicalEvent {
final String isin;
final bool forceRefresh;
final String? ticker;
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
}
@@ -0,0 +1,13 @@
import '../../models/technical_analysis_model.dart';
abstract class AssetTechnicalState {}
class AssetTechnicalInitial extends AssetTechnicalState {}
class AssetTechnicalLoading extends AssetTechnicalState {}
class AssetTechnicalLoaded extends AssetTechnicalState {
final TechnicalAnalysisModel? data;
AssetTechnicalLoaded(this.data);
}
class AssetTechnicalError extends AssetTechnicalState {
final String message;
AssetTechnicalError(this.message);
}
@@ -0,0 +1,52 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'asset_trades_event.dart';
import 'asset_trades_state.dart';
import '../../repositories/asset_repository.dart';
class AssetTradesBloc extends Bloc<AssetTradesEvent, AssetTradesState> {
final AssetRepository repository;
AssetTradesBloc({required this.repository}) : super(AssetTradesInitial()) {
on<LoadAssetTrades>((event, emit) async {
emit(AssetTradesLoading());
try {
final data = await repository.getAssetTrades(event.isin, event.status);
emit(AssetTradesLoaded(data));
} catch (e) {
emit(AssetTradesError(e.toString()));
}
});
on<TriggerManualAnalysis>((event, emit) async {
try {
await repository.triggerManualAnalysis(event.isin, payload: event.payload);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to trigger manual analysis: $e"));
}
});
on<RejectTradeEvent>((event, emit) async {
try {
await repository.rejectTrade(event.tradeId);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to reject trade: $e"));
}
});
on<AcceptTradeEvent>((event, emit) async {
try {
await repository.acceptTrade(event.tradeAcceptanceDto);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to accept trade: $e"));
}
});
on<CloseTradeEvent>((event, emit) async {
try {
await repository.closeTrade(event.tradeId, event.exitPrice);
add(LoadAssetTrades(event.isin));
} catch (e) {
emit(AssetTradesError("Failed to close trade: $e"));
}
});
}
}
@@ -0,0 +1,31 @@
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
abstract class AssetTradesEvent {}
class LoadAssetTrades extends AssetTradesEvent {
final String isin;
final String? status;
LoadAssetTrades(this.isin, {this.status});
}
class TriggerManualAnalysis extends AssetTradesEvent {
final String isin;
final ManualAnalysisRequestDto? payload;
TriggerManualAnalysis(this.isin, {this.payload});
}
class RejectTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
RejectTradeEvent(this.tradeId, this.isin);
}
class AcceptTradeEvent extends AssetTradesEvent {
final TradeAcceptanceDto tradeAcceptanceDto;
final String isin;
AcceptTradeEvent(this.tradeAcceptanceDto, this.isin);
}
class CloseTradeEvent extends AssetTradesEvent {
final String tradeId;
final String isin;
final double exitPrice;
CloseTradeEvent(this.tradeId, this.isin, this.exitPrice);
}
@@ -0,0 +1,13 @@
import '../../../trades/models/trade_model.dart';
abstract class AssetTradesState {}
class AssetTradesInitial extends AssetTradesState {}
class AssetTradesLoading extends AssetTradesState {}
class AssetTradesLoaded extends AssetTradesState {
final List<TradeModel> data;
AssetTradesLoaded(this.data);
}
class AssetTradesError extends AssetTradesState {
final String message;
AssetTradesError(this.message);
}
@@ -0,0 +1,106 @@
import 'package:equatable/equatable.dart';
class AssetModel extends Equatable {
final String isin;
final String symbol;
final String name;
final double currentPrice;
final String currency;
final String exchange;
final List<String> exchanges;
final List<AssetTickerOption> tickers;
final String image;
const AssetModel({
required this.isin,
required this.symbol,
required this.name,
this.currentPrice = 0.0,
required this.currency,
required this.exchange,
required this.exchanges,
required this.tickers,
required this.image,
});
factory AssetModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return AssetModel(
isin: json['isin']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? '',
name: json['name']?.toString() ?? '',
currentPrice: parseDouble(json['price'] ?? json['currentPrice']),
currency: json['currency']?.toString() ?? 'EUR',
exchange: json['exchange']?.toString() ?? 'XETRA',
exchanges: (json['exchanges'] as List?)?.map((e) => e.toString()).toList() ?? [],
tickers: (json['tickers'] as List?)
?.map((t) => AssetTickerOption.fromJson(t))
.toList() ??
[],
image: json['image']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'symbol': symbol,
'name': name,
'currentPrice': currentPrice,
'currency': currency,
'exchange': exchange,
'exchanges': exchanges,
'tickers': tickers.map((t) => t.toJson()).toList(),
'image': image,
};
}
@override
List<Object?> get props => [isin, symbol, name, currentPrice, currency, exchange, exchanges, tickers, image];
}
class AssetTickerOption extends Equatable {
final String ticker;
final String exchange;
final String tradingCurrency;
final double currentPrice;
const AssetTickerOption({
required this.ticker,
required this.exchange,
required this.tradingCurrency,
required this.currentPrice,
});
factory AssetTickerOption.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
return AssetTickerOption(
ticker: json['ticker']?.toString() ?? '',
exchange: json['exchange']?.toString() ?? 'XETRA',
tradingCurrency: json['tradingCurrency']?.toString() ?? json['currency']?.toString() ?? 'EUR',
currentPrice: parseDouble(json['currentPrice'] ?? json['price']),
);
}
Map<String, dynamic> toJson() {
return {
'ticker': ticker,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'currentPrice': currentPrice,
};
}
@override
List<Object?> get props => [ticker, exchange, tradingCurrency, currentPrice];
}
@@ -0,0 +1,528 @@
import 'package:equatable/equatable.dart';
class FundamentalDataModel extends Equatable {
final String isin;
final String primaryTicker;
final String ticker;
final String companyName;
final String? exchange;
final String? tradingCurrency;
final String? businessSummary;
final String? sector;
final String? industry;
final String? country;
final int? employees;
final double currentPrice;
final double dayChangeAbsolute;
final double dayChangePercent;
final double fiftyTwoWeekHigh;
final double fiftyTwoWeekLow;
final double marketCapitalization;
final double enterpriseValue;
final double? peRatioTrailing;
final double? peRatioForward;
final double? pegRatio;
final double? pbRatio;
final double? psRatio;
final double? evToEbitda;
final double? evToRevenue;
final double? grossMargin;
final double? operatingMargin;
final double? netProfitMargin;
final double? returnOnEquity;
final double? returnOnAssets;
final double? returnOnInvestedCapital;
final double? debtToEquity;
final double? currentRatio;
final double? quickRatio;
final double? interestCoverage;
final double? dividendYield;
final double? payoutRatio;
final String? exDividendDate;
final String? nextEarningsDate;
final double? percentHeldByInstitutions;
final double? percentHeldByInsiders;
final double? shortRatio;
final double? shortPercentOfFloat;
final String? consensusRating;
final double? priceTargetLow;
final double? priceTargetHigh;
final double? priceTargetMedian;
final double? priceTargetMean;
final List<CompanyExecutiveModel> executives;
final List<FinancialStatementModel> financialStatements;
final List<ForwardEstimateModel> estimates;
const FundamentalDataModel({
required this.isin,
required this.primaryTicker,
required this.ticker,
required this.companyName,
this.exchange,
this.tradingCurrency,
this.businessSummary,
this.sector,
this.industry,
this.country,
this.employees,
required this.currentPrice,
required this.dayChangeAbsolute,
required this.dayChangePercent,
required this.fiftyTwoWeekHigh,
required this.fiftyTwoWeekLow,
required this.marketCapitalization,
required this.enterpriseValue,
this.peRatioTrailing,
this.peRatioForward,
this.pegRatio,
this.pbRatio,
this.psRatio,
this.evToEbitda,
this.evToRevenue,
this.grossMargin,
this.operatingMargin,
this.netProfitMargin,
this.returnOnEquity,
this.returnOnAssets,
this.returnOnInvestedCapital,
this.debtToEquity,
this.currentRatio,
this.quickRatio,
this.interestCoverage,
this.dividendYield,
this.payoutRatio,
this.exDividendDate,
this.nextEarningsDate,
this.percentHeldByInstitutions,
this.percentHeldByInsiders,
this.shortRatio,
this.shortPercentOfFloat,
this.consensusRating,
this.priceTargetLow,
this.priceTargetHigh,
this.priceTargetMedian,
this.priceTargetMean,
required this.executives,
required this.financialStatements,
required this.estimates,
});
factory FundamentalDataModel.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic val) {
if (val == null) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
double? parseNullableDouble(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FundamentalDataModel(
isin: json['isin']?.toString() ?? '',
primaryTicker: json['primaryTicker']?.toString() ?? '',
ticker: json['ticker']?.toString() ?? '',
companyName: json['companyName']?.toString() ?? '',
exchange: json['exchange']?.toString(),
tradingCurrency: json['tradingCurrency']?.toString(),
businessSummary: json['businessSummary']?.toString(),
sector: json['sector']?.toString(),
industry: json['industry']?.toString(),
country: json['country']?.toString(),
employees: json['employees'] != null ? int.tryParse(json['employees'].toString()) : null,
currentPrice: parseDouble(json['currentPrice']),
dayChangeAbsolute: parseDouble(json['dayChangeAbsolute']),
dayChangePercent: parseDouble(json['dayChangePercent']),
fiftyTwoWeekHigh: parseDouble(json['fiftyTwoWeekHigh']),
fiftyTwoWeekLow: parseDouble(json['fiftyTwoWeekLow']),
marketCapitalization: parseDouble(json['marketCapitalization'] ?? json['marketCap']),
enterpriseValue: parseDouble(json['enterpriseValue']),
peRatioTrailing: parseNullableDouble(json['peRatioTrailing'] ?? json['peRatio']),
peRatioForward: parseNullableDouble(json['peRatioForward']),
pegRatio: parseNullableDouble(json['pegRatio']),
pbRatio: parseNullableDouble(json['pbRatio']),
psRatio: parseNullableDouble(json['psRatio']),
evToEbitda: parseNullableDouble(json['evToEbitda']),
evToRevenue: parseNullableDouble(json['evToRevenue']),
grossMargin: parseNullableDouble(json['grossMargin']),
operatingMargin: parseNullableDouble(json['operatingMargin']),
netProfitMargin: parseNullableDouble(json['netProfitMargin']),
returnOnEquity: parseNullableDouble(json['returnOnEquity']),
returnOnAssets: parseNullableDouble(json['returnOnAssets']),
returnOnInvestedCapital: parseNullableDouble(json['returnOnInvestedCapital']),
debtToEquity: parseNullableDouble(json['debtToEquity']),
currentRatio: parseNullableDouble(json['currentRatio']),
quickRatio: parseNullableDouble(json['quickRatio']),
interestCoverage: parseNullableDouble(json['interestCoverage']),
dividendYield: parseNullableDouble(json['dividendYield']),
payoutRatio: parseNullableDouble(json['payoutRatio']),
exDividendDate: json['exDividendDate']?.toString(),
nextEarningsDate: json['nextEarningsDate']?.toString(),
percentHeldByInstitutions: parseNullableDouble(json['percentHeldByInstitutions']),
percentHeldByInsiders: parseNullableDouble(json['percentHeldByInsiders']),
shortRatio: parseNullableDouble(json['shortRatio']),
shortPercentOfFloat: parseNullableDouble(json['shortPercentOfFloat']),
consensusRating: json['consensusRating']?.toString(),
priceTargetLow: parseNullableDouble(json['priceTargetLow']),
priceTargetHigh: parseNullableDouble(json['priceTargetHigh']),
priceTargetMedian: parseNullableDouble(json['priceTargetMedian']),
priceTargetMean: parseNullableDouble(json['priceTargetMean']),
executives: (json['executives'] as List?)
?.map((e) => CompanyExecutiveModel.fromJson(e))
.toList() ??
[],
financialStatements: (json['financialStatements'] as List?)
?.map((e) => FinancialStatementModel.fromJson(e))
.toList() ??
[],
estimates: (json['estimates'] as List?)
?.map((e) => ForwardEstimateModel.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'primaryTicker': primaryTicker,
'ticker': ticker,
'companyName': companyName,
'exchange': exchange,
'tradingCurrency': tradingCurrency,
'businessSummary': businessSummary,
'sector': sector,
'industry': industry,
'country': country,
'employees': employees,
'currentPrice': currentPrice,
'dayChangeAbsolute': dayChangeAbsolute,
'dayChangePercent': dayChangePercent,
'fiftyTwoWeekHigh': fiftyTwoWeekHigh,
'fiftyTwoWeekLow': fiftyTwoWeekLow,
'marketCapitalization': marketCapitalization,
'enterpriseValue': enterpriseValue,
'peRatioTrailing': peRatioTrailing,
'peRatioForward': peRatioForward,
'pegRatio': pegRatio,
'pbRatio': pbRatio,
'psRatio': psRatio,
'evToEbitda': evToEbitda,
'evToRevenue': evToRevenue,
'grossMargin': grossMargin,
'operatingMargin': operatingMargin,
'netProfitMargin': netProfitMargin,
'returnOnEquity': returnOnEquity,
'returnOnAssets': returnOnAssets,
'returnOnInvestedCapital': returnOnInvestedCapital,
'debtToEquity': debtToEquity,
'currentRatio': currentRatio,
'quickRatio': quickRatio,
'dividendYield': dividendYield,
'payoutRatio': payoutRatio,
'exDividendDate': exDividendDate,
'nextEarningsDate': nextEarningsDate,
'percentHeldByInstitutions': percentHeldByInstitutions,
'percentHeldByInsiders': percentHeldByInsiders,
'shortRatio': shortRatio,
'shortPercentOfFloat': shortPercentOfFloat,
'consensusRating': consensusRating,
'priceTargetLow': priceTargetLow,
'priceTargetHigh': priceTargetHigh,
'priceTargetMedian': priceTargetMedian,
'priceTargetMean': priceTargetMean,
'executives': executives.map((e) => e.toJson()).toList(),
'financialStatements': financialStatements.map((e) => e.toJson()).toList(),
'estimates': estimates.map((e) => e.toJson()).toList(),
};
}
@override
List<Object?> get props => [
isin,
primaryTicker,
ticker,
companyName,
exchange,
tradingCurrency,
businessSummary,
sector,
industry,
country,
employees,
currentPrice,
dayChangeAbsolute,
dayChangePercent,
fiftyTwoWeekHigh,
fiftyTwoWeekLow,
marketCapitalization,
enterpriseValue,
peRatioTrailing,
peRatioForward,
pegRatio,
pbRatio,
psRatio,
evToEbitda,
evToRevenue,
grossMargin,
operatingMargin,
netProfitMargin,
returnOnEquity,
returnOnAssets,
returnOnInvestedCapital,
debtToEquity,
currentRatio,
quickRatio,
dividendYield,
payoutRatio,
exDividendDate,
nextEarningsDate,
percentHeldByInstitutions,
percentHeldByInsiders,
shortRatio,
shortPercentOfFloat,
consensusRating,
priceTargetLow,
priceTargetHigh,
priceTargetMedian,
priceTargetMean,
executives,
financialStatements,
estimates,
];
}
class CompanyExecutiveModel extends Equatable {
final String name;
final String title;
final int? age;
final double? compensation;
const CompanyExecutiveModel({
required this.name,
required this.title,
this.age,
this.compensation,
});
factory CompanyExecutiveModel.fromJson(Map<String, dynamic> json) {
return CompanyExecutiveModel(
name: json['name']?.toString() ?? '',
title: json['title']?.toString() ?? '',
age: json['age'] != null ? int.tryParse(json['age'].toString()) : null,
compensation: json['compensation'] != null ? double.tryParse(json['compensation'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'title': title,
'age': age,
'compensation': compensation,
};
}
@override
List<Object?> get props => [name, title, age, compensation];
}
class FinancialStatementModel extends Equatable {
final String periodType;
final String endDate;
// Income Statement
final double? totalRevenue;
final double? costOfRevenue;
final double? grossProfit;
final double? operatingExpenses;
final double? operatingIncome;
final double? ebitda;
final double? netIncome;
final double? epsBasic;
final double? epsDiluted;
// Balance Sheet
final double? cashAndCashEquivalents;
final double? accountsReceivable;
final double? inventory;
final double? totalCurrentAssets;
final double? totalNonCurrentAssets;
final double? currentLiabilities;
final double? longTermDebt;
final double? totalLiabilities;
final double? totalStockholdersEquity;
// Cash Flow
final double? operatingCashFlow;
final double? investingCashFlow;
final double? capitalExpenditures;
final double? financingCashFlow;
final double? freeCashFlow;
const FinancialStatementModel({
required this.periodType,
required this.endDate,
this.totalRevenue,
this.costOfRevenue,
this.grossProfit,
this.operatingExpenses,
this.operatingIncome,
this.ebitda,
this.netIncome,
this.epsBasic,
this.epsDiluted,
this.cashAndCashEquivalents,
this.accountsReceivable,
this.inventory,
this.totalCurrentAssets,
this.totalNonCurrentAssets,
this.currentLiabilities,
this.longTermDebt,
this.totalLiabilities,
this.totalStockholdersEquity,
this.operatingCashFlow,
this.investingCashFlow,
this.capitalExpenditures,
this.financingCashFlow,
this.freeCashFlow,
});
factory FinancialStatementModel.fromJson(Map<String, dynamic> json) {
double? parseD(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
return FinancialStatementModel(
periodType: json['periodType']?.toString() ?? '',
endDate: json['endDate']?.toString() ?? '',
totalRevenue: parseD(json['totalRevenue']),
costOfRevenue: parseD(json['costOfRevenue']),
grossProfit: parseD(json['grossProfit']),
operatingExpenses: parseD(json['operatingExpenses']),
operatingIncome: parseD(json['operatingIncome']),
ebitda: parseD(json['ebitda']),
netIncome: parseD(json['netIncome']),
epsBasic: parseD(json['epsBasic']),
epsDiluted: parseD(json['epsDiluted']),
cashAndCashEquivalents: parseD(json['cashAndCashEquivalents']),
accountsReceivable: parseD(json['accountsReceivable']),
inventory: parseD(json['inventory']),
totalCurrentAssets: parseD(json['totalCurrentAssets']),
totalNonCurrentAssets: parseD(json['totalNonCurrentAssets']),
currentLiabilities: parseD(json['currentLiabilities']),
longTermDebt: parseD(json['longTermDebt']),
totalLiabilities: parseD(json['totalLiabilities']),
totalStockholdersEquity: parseD(json['totalStockholdersEquity']),
operatingCashFlow: parseD(json['operatingCashFlow']),
investingCashFlow: parseD(json['investingCashFlow']),
capitalExpenditures: parseD(json['capitalExpenditures']),
financingCashFlow: parseD(json['financingCashFlow']),
freeCashFlow: parseD(json['freeCashFlow']),
);
}
Map<String, dynamic> toJson() {
return {
'periodType': periodType,
'endDate': endDate,
'totalRevenue': totalRevenue,
'costOfRevenue': costOfRevenue,
'grossProfit': grossProfit,
'operatingExpenses': operatingExpenses,
'operatingIncome': operatingIncome,
'ebitda': ebitda,
'netIncome': netIncome,
'epsBasic': epsBasic,
'epsDiluted': epsDiluted,
'cashAndCashEquivalents': cashAndCashEquivalents,
'accountsReceivable': accountsReceivable,
'inventory': inventory,
'totalCurrentAssets': totalCurrentAssets,
'totalNonCurrentAssets': totalNonCurrentAssets,
'currentLiabilities': currentLiabilities,
'longTermDebt': longTermDebt,
'totalLiabilities': totalLiabilities,
'totalStockholdersEquity': totalStockholdersEquity,
'operatingCashFlow': operatingCashFlow,
'investingCashFlow': investingCashFlow,
'capitalExpenditures': capitalExpenditures,
'financingCashFlow': financingCashFlow,
'freeCashFlow': freeCashFlow,
};
}
@override
List<Object?> get props => [
periodType,
endDate,
totalRevenue,
costOfRevenue,
grossProfit,
operatingExpenses,
operatingIncome,
ebitda,
netIncome,
epsBasic,
epsDiluted,
cashAndCashEquivalents,
accountsReceivable,
inventory,
totalCurrentAssets,
totalNonCurrentAssets,
currentLiabilities,
longTermDebt,
totalLiabilities,
totalStockholdersEquity,
operatingCashFlow,
investingCashFlow,
capitalExpenditures,
financingCashFlow,
freeCashFlow,
];
}
class ForwardEstimateModel extends Equatable {
final String period;
final double? expectedRevenue;
final double? expectedEps;
final double? expectedGrowthRate;
const ForwardEstimateModel({
required this.period,
this.expectedRevenue,
this.expectedEps,
this.expectedGrowthRate,
});
factory ForwardEstimateModel.fromJson(Map<String, dynamic> json) {
return ForwardEstimateModel(
period: json['period']?.toString() ?? '',
expectedRevenue: json['expectedRevenue'] != null ? double.tryParse(json['expectedRevenue'].toString()) : null,
expectedEps: json['expectedEps'] != null ? double.tryParse(json['expectedEps'].toString()) : null,
expectedGrowthRate: json['expectedGrowthRate'] != null ? double.tryParse(json['expectedGrowthRate'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'period': period,
'expectedRevenue': expectedRevenue,
'expectedEps': expectedEps,
'expectedGrowthRate': expectedGrowthRate,
};
}
@override
List<Object?> get props => [period, expectedRevenue, expectedEps, expectedGrowthRate];
}
@@ -0,0 +1,51 @@
class ManualAnalysisRequestDto {
final String isin;
final String symbol;
final int riskScore;
final int minTimeframeValue;
final int maxTimeframeValue;
final String timeframeUnit;
final String instrumentType;
final String userNotes;
final String headline;
ManualAnalysisRequestDto({
required this.isin,
required this.symbol,
required this.riskScore,
required this.minTimeframeValue,
required this.maxTimeframeValue,
required this.timeframeUnit,
required this.instrumentType,
required this.userNotes,
required this.headline,
});
Map<String, dynamic> toJson() {
return {
'isin': isin,
'symbol': symbol,
'riskScore': riskScore,
'minTimeframeValue': minTimeframeValue,
'maxTimeframeValue': maxTimeframeValue,
'timeframeUnit': timeframeUnit,
'instrumentType': instrumentType,
'userNotes': userNotes,
'headline': headline,
};
}
factory ManualAnalysisRequestDto.fromJson(Map<String, dynamic> json) {
return ManualAnalysisRequestDto(
isin: json['isin'] as String,
symbol: json['symbol'] as String,
riskScore: json['riskScore'] as int,
minTimeframeValue: json['minTimeframeValue'] as int,
maxTimeframeValue: json['maxTimeframeValue'] as int,
timeframeUnit: json['timeframeUnit'] as String,
instrumentType: json['instrumentType'] as String,
userNotes: json['userNotes'] as String,
headline: json['headline'] as String,
);
}
}
@@ -0,0 +1,209 @@
import 'package:equatable/equatable.dart';
class CandleModel extends Equatable {
final DateTime timestamp;
final double open;
final double high;
final double low;
final double close;
final double volume;
const CandleModel({
required this.timestamp,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(),
open: (json['open'] as num?)?.toDouble() ?? 0.0,
high: (json['high'] as num?)?.toDouble() ?? 0.0,
low: (json['low'] as num?)?.toDouble() ?? 0.0,
close: (json['close'] as num?)?.toDouble() ?? 0.0,
volume: (json['volume'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [timestamp, open, high, low, close, volume];
}
class IndicatorModel extends Equatable {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? rsi14;
final double? macdLine;
final double? macdSignal;
final double? macdHistogram;
final double? atr14;
final double? vwap;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
final double? recommendedStopLoss;
const IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.rsi14,
this.macdLine,
this.macdSignal,
this.macdHistogram,
this.atr14,
this.vwap,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
this.recommendedStopLoss,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(),
ema20: (json['ema20'] as num?)?.toDouble(),
sma50: (json['sma50'] as num?)?.toDouble(),
sma200: (json['sma200'] as num?)?.toDouble(),
rsi14: (json['rsi14'] as num?)?.toDouble(),
macdLine: (json['macdLine'] as num?)?.toDouble(),
macdSignal: (json['macdSignal'] as num?)?.toDouble(),
macdHistogram: (json['macdHistogram'] as num?)?.toDouble(),
atr14: (json['atr14'] as num?)?.toDouble(),
vwap: (json['vwap'] as num?)?.toDouble(),
supertrendUpper: (json['supertrendUpper'] as num?)?.toDouble(),
supertrendLower: (json['supertrendLower'] as num?)?.toDouble(),
supertrendDirection: json['supertrendDirection']?.toString(),
recommendedStopLoss: (json['recommendedStopLoss'] as num?)?.toDouble(),
);
}
@override
List<Object?> get props => [
timestamp, ema20, sma50, sma200, rsi14, macdLine, macdSignal,
macdHistogram, atr14, vwap, supertrendUpper, supertrendLower,
supertrendDirection, recommendedStopLoss
];
}
class StrategySignalModel extends Equatable {
final String title;
final DateTime date;
final double price;
final String type; // BUY or SELL
const StrategySignalModel({
required this.title,
required this.date,
required this.price,
required this.type,
});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
return StrategySignalModel(
title: json['title']?.toString() ?? '',
date: DateTime.tryParse(json['date']?.toString() ?? '') ?? DateTime.now(),
price: (json['price'] as num?)?.toDouble() ?? 0.0,
type: json['type']?.toString() ?? 'BUY',
);
}
@override
List<Object?> get props => [title, date, price, type];
}
class TechnicalAnalysisModel extends Equatable {
final String symbol;
final String trend;
final String rsi;
final String macd;
final String overallSignal;
final String sma50;
final String sma200;
final double vix;
final String sp500Trend;
final double dxy;
final double? stopLossAtr;
final List<CandleModel> candles;
final List<IndicatorModel> indicators;
final List<String> patterns;
final List<StrategySignalModel> signals;
const TechnicalAnalysisModel({
required this.symbol,
required this.trend,
required this.rsi,
required this.macd,
required this.overallSignal,
required this.sma50,
required this.sma200,
this.vix = 16.5,
this.sp500Trend = 'Bullish',
this.dxy = 104.2,
this.stopLossAtr,
this.candles = const [],
this.indicators = const [],
this.patterns = const [],
this.signals = const [],
});
factory TechnicalAnalysisModel.fromJson(Map<String, dynamic> json) {
var rawCandles = json['candles'] as List<dynamic>? ?? [];
var candlesList = rawCandles.map((c) => CandleModel.fromJson(c as Map<String, dynamic>)).toList();
var rawIndicators = json['indicators'] as List<dynamic>? ?? [];
var indicatorsList = rawIndicators.map((i) => IndicatorModel.fromJson(i as Map<String, dynamic>)).toList();
var rawSignals = json['signals'] as List<dynamic>? ?? [];
var signalsList = rawSignals.map((s) => StrategySignalModel.fromJson(s as Map<String, dynamic>)).toList();
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
var patternsList = rawPatterns.map((p) => p.toString()).toList();
return TechnicalAnalysisModel(
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
trend: json['trend']?.toString() ?? json['Trend']?.toString() ?? 'Bullisch ▲',
rsi: json['rsi']?.toString() ?? json['Rsi']?.toString() ?? '58.7',
macd: json['macd']?.toString() ?? json['Macd']?.toString() ?? '0.45',
overallSignal: json['overallSignal']?.toString() ?? json['OverallSignal']?.toString() ?? 'HOLD',
sma50: json['sma50']?.toString() ?? json['Sma50']?.toString() ?? '49.50',
sma200: json['sma200']?.toString() ?? json['Sma200']?.toString() ?? '42.50',
vix: (json['vix'] as num?)?.toDouble() ?? 16.5,
sp500Trend: json['sp500Trend']?.toString() ?? 'Bullish',
dxy: (json['dxy'] as num?)?.toDouble() ?? 104.2,
stopLossAtr: (json['stopLossAtr'] as num?)?.toDouble(),
candles: candlesList,
indicators: indicatorsList,
patterns: patternsList,
signals: signalsList,
);
}
Map<String, dynamic> toJson() {
return {
'symbol': symbol,
'trend': trend,
'rsi': rsi,
'macd': macd,
'overallSignal': overallSignal,
'sma50': sma50,
'sma200': sma200,
'vix': vix,
'sp500Trend': sp500Trend,
'dxy': dxy,
'stopLossAtr': stopLossAtr,
};
}
@override
List<Object?> get props => [
symbol, trend, rsi, macd, overallSignal, sma50, sma200, vix,
sp500Trend, dxy, stopLossAtr, candles, indicators, patterns, signals
];
}
@@ -0,0 +1,145 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/asset_detail/models/asset_model.dart';
import 'package:finlytic_app/features/asset_detail/models/fundamental_data_model.dart';
import 'package:finlytic_app/features/asset_detail/models/technical_analysis_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/trades/models/trade_acceptance_dto.dart';
class AssetRepository {
final ApiClient apiClient;
AssetRepository({required this.apiClient});
Future<void> forceRefreshFundamentalData(String symbol) async {
try {
await apiClient.post('/api/v1/assets/$symbol/refresh');
} catch (e) {
print('Error forcing refresh: $e');
}
}
Future<FundamentalDataModel?> getFundamentalData(String symbol) async {
try {
final res = await apiClient.get('/api/v1/assets/fundamentals/$symbol');
if (res.statusCode == 200 && res.data != null) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching fundamentals for $symbol: $e');
}
return null;
}
Future<TechnicalAnalysisModel?> getTechnicalAnalysis(String symbol) async {
try {
final res = await apiClient.get('/api/v1/ta/$symbol');
if (res.statusCode == 200 && res.data != null) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching TA for $symbol: $e');
}
return null;
}
Future<AssetModel?> getAssetHeader(String isin, {String? exchange, String? ticker}) async {
try {
String url = '/api/v1/assets/header/$isin?';
if (ticker != null && ticker.isNotEmpty) {
url += 'ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
return AssetModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching asset header for $isin: $e');
}
return null;
}
Future<FundamentalDataModel?> getAssetFundamentals(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/assets/fundamentals/$isin?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
return FundamentalDataModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching fundamentals for $isin: $e');
}
return null;
}
Future<TechnicalAnalysisModel?> getAssetTechnical(String isin, bool forceRefresh, {String? ticker}) async {
try {
String url = '/api/v1/ta/$isin?forceRefresh=$forceRefresh';
if (ticker != null && ticker.isNotEmpty) {
url += '&ticker=$ticker';
}
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
return TechnicalAnalysisModel.fromJson(res.data);
}
} catch (e) {
print('Error fetching TA for $isin: $e');
}
return null;
}
Future<List<TradeModel>> getAssetTrades(String isin, String? status) async {
try {
String url = '/api/v1/trades?isin=$isin';
if (status != null) url += '&status=$status';
final res = await apiClient.get(url);
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> list = res.data;
return list.map((json) => TradeModel.fromJson(json)).toList();
}
} catch (e) {
print('Error fetching trades for $isin: $e');
}
return [];
}
Future<void> triggerManualAnalysis(String isin, {ManualAnalysisRequestDto? payload}) async {
try {
final body = payload != null ? payload.toJson() : {'isin': isin};
await apiClient.post('/api/v1/analyze/manual', data: body);
} catch (e) {
print('Error triggering manual analysis for $isin: $e');
throw e;
}
}
Future<void> rejectTrade(String tradeId) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/reject');
} catch (e) {
print('Error rejecting trade $tradeId: $e');
throw e;
}
}
Future<void> acceptTrade(TradeAcceptanceDto tradeAcceptanceDto) async {
try {
final payload = tradeAcceptanceDto.toJson();
await apiClient.post('/api/v1/user/trades/accept', data: payload);
} catch (e) {
print('Error accepting trade: $e');
throw e;
}
}
Future<void> closeTrade(String tradeId, double exitPrice) async {
try {
await apiClient.post('/api/v1/user/trades/$tradeId/close', data: {'userExitPrice': exitPrice});
} catch (e) {
print('Error closing trade $tradeId: $e');
throw e;
}
}
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../widgets/metric_explanation_modal.dart';
class MetricExplanations {
static const Map<String, Map<String, String>> data = {
'EMA (20)': {
'title': 'Exponential Moving Average (20 Perioden)',
'formula': 'EMA_t = (Preis_t * (2 / (20 + 1))) + EMA_{t-1} * (1 - (2 / (20 + 1)))',
'description': 'Exponentiell gewichteter gleitender Durchschnitt der letzten 20 Kerzen. Gewichtete aktuelle Kurse stärker als ältere.',
'tradingSignificance': 'Dient als dynamische Unterstützung/Widerstand für kurzfristige Trends. Ein Schnittpunkt über die Kerzen zeigt Kaufsignale.',
},
'SMA (50)': {
'title': 'Simple Moving Average (50 Perioden)',
'formula': 'SMA = (Summe der Schlusskurse der letzten 50 Kerzen) / 50',
'description': 'Einfacher gleitender Durchschnitt der letzten 50 Perioden.',
'tradingSignificance': 'Standard-Indikator für mittelfristige Trends. Preis über SMA50 deutet auf einen intakten Aufwärtstrend hin.',
},
'SMA (200)': {
'title': 'Simple Moving Average (200 Perioden)',
'formula': 'SMA = (Summe der Schlusskurse der letzten 200 Kerzen) / 200',
'description': 'Einfacher gleitender Durchschnitt der letzten 200 Perioden.',
'tradingSignificance': 'Wichtigster Indikator für den langfristigen Trend. "Golden Cross" (SMA50 schneidet SMA200 nach oben) ist ein starkes Bullen-Signal.',
},
'Supertrend': {
'title': 'Supertrend Indikator',
'formula': 'Upper/Lower Band = (High + Low)/2 ± (Multiplier * ATR(10))',
'description': 'Kombiniert ATR (Average True Range) und Durchschnittskurs zur Trendfolge.',
'tradingSignificance': 'Grün zeigt einen etablierten Aufwärtstrend mit dynamischem Stop-Loss Level; Rot signalisiert Abwärtstrend.',
},
'RSI (14)': {
'title': 'Relative Strength Index (14 Perioden)',
'formula': 'RSI = 100 - (100 / (1 + (Durchschnittl. Gewinn / Durchschnittl. Verlust)))',
'description': 'Oszillator zur Messung der Geschwindigkeit und Veränderung von Kursbewegungen.',
'tradingSignificance': 'Werte > 70 gelten als überkauft (Verkaufsrisiko), Werte < 30 gelten als überverkauft (Kaufchance).',
},
'KGV (Trailing P/E)': {
'title': 'Kurs-Gewinn-Verhältnis (Trailing P/E)',
'formula': 'KGV = Aktienkurs / Gewinn pro Aktie (EPS der letzten 12 Monate)',
'description': 'Gibt an, das Wievielfache des Jahresgewinns für eine Aktie gezahlt wird.',
'tradingSignificance': 'Ein niedriges KGV kann auf eine Unterbewertung hindeuten; ein hohes KGV verlangt hohes zukünftiges Gewinnwachstum.',
},
'KGV (Forward P/E)': {
'title': 'Zukünftiges KGV (Forward P/E)',
'formula': 'Forward KGV = Aktueller Kurs / Erwarteter Gewinn pro Aktie (nächste 12 Monate)',
'description': 'Basiert auf den Konsens-Gewinnerwartungen von Analysten für das kommende Jahr.',
'tradingSignificance': 'Ermöglicht den Vergleich mit dem historischen KGV, um festzustellen, ob das Gewinnwachstum die Bewertung verbilligt.',
},
'PEG Ratio': {
'title': 'Price/Earnings-to-Growth Ratio',
'formula': 'PEG = KGV / Zukünftiges Gewinnwachstum in %',
'description': 'Setzt das KGV ins Verhältnis zum erwarteten Gewinnwachstum des Unternehmens.',
'tradingSignificance': 'PEG < 1.0 gilt als fair oder unterbewertet im Verhältnis zum Wachstum. PEG > 2.0 gilt als teuer.',
},
'KBV (P/B Ratio)': {
'title': 'Kurs-Buchwert-Verhältnis (P/B Ratio)',
'formula': 'KBV = Aktienkurs / Buchwert pro Aktie',
'description': 'Vergleicht den Börsenwert des Unternehmens mit seinem bilanziellen Eigenkapital.',
'tradingSignificance': 'Besonders wichtig für Finanzwerte und Substanzwerte. KBV < 1 bedeutet, dass die Aktie unter ihrem Buchwert handelt.',
},
'KUV (P/S Ratio)': {
'title': 'Kurs-Umsatz-Verhältnis (P/S Ratio)',
'formula': 'KUV = Marktkapitalisierung / Gesamter Jahresumsatz',
'description': 'Vergleicht den Marktwert des Unternehmens mit seinem Jahresumsatz.',
'tradingSignificance': 'Nützlich bei noch unprofitablen Wachstumsunternehmen, bei denen noch kein positives KGV berechnet werden kann.',
},
'EV / EBITDA': {
'title': 'Enterprise Value zu EBITDA',
'formula': 'EV/EBITDA = Enterprise Value / (Gewinn vor Zinsen, Steuern & Abschreibungen)',
'description': 'Misst den Unternehmenswert inklusive Schulden im Verhältnis zur operativen Cash-Generierung.',
'tradingSignificance': 'Kapitalstruktur-neutraler Bewertungs-Multiple. Erlaubt fairen Vergleich zwischen Unternehmen mit unterschiedlicher Verschuldung.',
},
'EV / Sales': {
'title': 'Enterprise Value zu Umsatz',
'formula': 'EV/Sales = Enterprise Value / Jahresumsatz',
'description': 'Vergleicht den gesamten Unternehmenswert (Eigen- + Fremdkapital) mit den Erlösen.',
'tradingSignificance': 'Robustere Kennzahl als KUV, da sie auch die Schuldenlast des Unternehmens berücksichtigt.',
},
'Enterprise Value': {
'title': 'Enterprise Value (Unternehmenswert)',
'formula': 'EV = Marktkapitalisierung + Gesamtschulden - Liquide Mittel (Cash)',
'description': 'Der theoretische Übernahmepreis für das gesamte Unternehmen inklusive Tilgung aller Verbindlichkeiten.',
'tradingSignificance': 'Der tatsächliche wirtschaftliche Wert des Geschäftsbetriebs.',
},
'Marktkapitalisierung': {
'title': 'Marktkapitalisierung (Market Cap)',
'formula': 'Market Cap = Gesamtzahl ausstehender Aktien * Aktueller Aktienkurs',
'description': 'Der Gesamtwert aller frei gehandelten Aktien des Unternehmens an der Börse.',
'tradingSignificance': 'Teilt Unternehmen in Large Cap (>10 Mrd. €), Mid Cap (2-10 Mrd. €) und Small Cap (<2 Mrd. €) ein.',
},
'Short Ratio': {
'title': 'Days to Cover (Short Ratio)',
'formula': 'Short Ratio = Anzahl leerverkaufter Aktien / Durchschnittliches Tagesvolumen',
'description': 'Gibt an, wie viele Handelstage Leerverkäufer bräuchten, um alle Positionen einzudecken.',
'tradingSignificance': 'Hohe Werte (> 5-7 Tage) erhöhen die Wahrscheinlichkeit eines heftigen "Short Squeezes" bei positiven News.',
},
'Bruttomarge (Gross)': {
'title': 'Bruttogewinnmarge (Gross Margin)',
'formula': 'Gross Margin = ((Umsatz - Herstellkosten) / Umsatz) * 100',
'description': 'Prozentualer Anteil des Umsatzes, der nach Abzug der direkten Produktionskosten verbleibt.',
'tradingSignificance': 'Hohe Bruttomargen (> 50-70%) zeigen eine starke Preissetzungsmacht und Wettbewerbsvorteile (Moat).',
},
'Operative Marge': {
'title': 'Operative Gewinnmarge (EBIT Margin)',
'formula': 'Operating Margin = (Operatives Ergebnis (EBIT) / Umsatz) * 100',
'description': 'Prozentualer Anteil des Umsatzes, der nach allen operativen Kosten (F&E, Vertrieb, Admin) übrig bleibt.',
'tradingSignificance': 'Kerngröße für die operative Effizienz des Managements.',
},
'Nettogewinnmarge': {
'title': 'Nettogewinnmarge (Net Profit Margin)',
'formula': 'Net Profit Margin = (Nettogewinn nach Steuern / Umsatz) * 100',
'description': 'Prozentualer Reingewinn, der von jedem Euro Umsatz im Unternehmen verbleibt.',
'tradingSignificance': 'Zeigt die finale Rentabilität nach allen Zinsen und Steuern.',
},
'Eigenkapitalrendite (ROE)': {
'title': 'Eigenkapitalrendite (Return on Equity)',
'formula': 'ROE = (Nettogewinn / Eigenkapital) * 100',
'description': 'Misst, wie effizient das Management das eingesetzte Eigenkapital verzinst.',
'tradingSignificance': 'Werte > 15-20% stehen für hochprofitable Qualitätsunternehmen.',
},
'Verschuldungsgrad (D/E)': {
'title': 'Debt-to-Equity Ratio (D/E)',
'formula': 'D/E = Gesamtschulden / Eigenkapital',
'description': 'Setzt das Fremdkapital ins Verhältnis zum Eigenkapital.',
'tradingSignificance': 'Werte > 1.5 - 2.0 deuten auf ein erhöhtes mehraufwand- und Insolvenzrisiko bei steigenden Zinsen hin.',
},
};
static void show(BuildContext context, String key) {
final info = data[key];
if (info == null) return;
showDialog(
context: context,
builder: (context) => MetricExplanationModal(
title: info['title']!,
formula: info['formula']!,
description: info['description']!,
tradingSignificance: info['tradingSignificance']!,
),
);
}
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class PatternExplanations {
static const Map<String, Map<String, String>> dictionary = {
'ASCENDING_TRIANGLE': {
'title': 'Steigendes Dreieck (Ascending Triangle)',
'bias': 'BULLISH',
'description': 'Ein bullisches Fortsetzungsmuster, das durch eine horizontale Widerstandslinie oben und eine steigende Unterstützungslinie unten gekennzeichnet ist.',
'significance': 'Käufer werden bei jedem Rücksetzer aggressiver (höhere Tiefs). Ein Ausbruch über die obere Widerstandslinie signalisiert eine starke Fortsetzung des Aufwärtstrends.',
'action': 'Kauf-Order / Breakout-Trade beim Ausbruch über den horizontalen Widerstand mit Stop-Loss knapp unter der steigenden Trendlinie.',
},
'DESCENDING_TRIANGLE': {
'title': 'Fallendes Dreieck (Descending Triangle)',
'bias': 'BEARISH',
'description': 'Ein bärisches Fortsetzungsmuster mit einer horizontalen Unterstützungslinie unten und fallenden Hochs oben.',
'significance': 'Verkäufer drücken den Kurs bei jeder Erholung schneller nach unten. Ein Bruch der unteren Unterstützung führt meist zu dynamischen Abverkäufen.',
'action': 'Short-Trade oder Verkauf bei Durchbruch der unteren Unterstützungslinie.',
},
'HEAD_AND_SHOULDERS': {
'title': 'Kopf-Schulter-Formation (Head & Shoulders)',
'bias': 'BEARISH',
'description': 'Klassisches Umkehrmuster bestehend aus drei Höchstständen: der mittleren höchsten Spitze (Kopf) und zwei kleineren Höchstständen links und rechts (Schultern).',
'significance': 'Ein nachhaltiger Bruch der Nackenlinie (Neckline) markiert das Ende eines Aufwärtstrends und den Beginn einer Bärenphase.',
'action': 'Verkauf/Short-Position beim Bruch der Nackenlinie mit Kursziel entsprechend der Distanz zwischen Kopf und Nackenlinie.',
},
'INVERSE_HEAD_AND_SHOULDERS': {
'title': 'Umgekehrte Kopf-Schulter-Formation',
'bias': 'BULLISH',
'description': 'Bullisches Bodenbildungsmuster nach einem Abwärtstrend mit drei Tiefspunkten.',
'significance': 'Signalisiert das Ende des Abwärtstrends und den Beginn eines neuen Bullenmarktes.',
'action': 'Kauf bei Ausbruch über die obere Nackenlinie.',
},
'BULL_FLAG': {
'title': 'Bullische Flagge (Bull Flag)',
'bias': 'BULLISH',
'description': 'Kurze Konsolidierung gegen den übergeordneten starken Aufwärtstrend (Fahnenstange).',
'significance': 'Zeigt eine temporäre Gewinnmitnahme vor der nächsten Welle nach oben.',
'action': 'Kauf beim Ausbruch aus der oberen Begrenzung des Flaggenkanals.',
},
'BEAR_FLAG': {
'title': 'Bärische Flagge (Bear Flag)',
'bias': 'BEARISH',
'description': 'Kurze Aufwärtskonsolidierung in einem steilen Abwärtstrend.',
'significance': 'Signalisiert eine Fortsetzung des steilen Abverkaufs.',
'action': 'Short-Position bei Durchbrechen der unteren Flaggenkante.',
},
'DOUBLE_BOTTOM': {
'title': 'Doppelboden (W-Formation)',
'bias': 'BULLISH',
'description': 'Zwei aufeinanderfolgende Tiefpunkte auf etwa gleichem Kursniveau.',
'significance': 'Starke Unterstützung auf dem Tiefststand wurde zweimal erfolgreich verteidigt. Ausbruch über das Zwischenhoch bestätigt W-Boden.',
'action': 'Kauf bei Überschreiten des W-Zwischenhochs.',
},
'DOUBLE_TOP': {
'title': 'Doppeltopp (M-Formation)',
'bias': 'BEARISH',
'description': 'Zwei markante Höchststände auf ähnlicher Höhe, die nicht durchbrochen werden konnten.',
'significance': 'Widerstandszone ist zu stark für die Bullen. Bruch des Zwischen-Tiefs leitet Trendwende ein.',
'action': 'Verkauf/Short bei Bruch des Zwischentiefs.',
},
'CHANNEL': {
'title': 'Trendkanal (Trading Channel)',
'bias': 'NEUTRAL',
'description': 'Parallele obere und untere Trendlinien, zwischen denen der Kurs Oszilliert.',
'significance': 'Erlaubt Swing-Trading zwischen den Kanallinien oder Breakout-Trading beim Ausbruch.',
'action': 'Kauf an der Unterkante, Verkauf an der Oberkante oder Breakout-Trading.',
},
'SUPPORT_RESISTANCE': {
'title': 'Unterstützungs- & Widerstandslinien',
'bias': 'NEUTRAL',
'description': 'Preisniveaus, an denen historisch gehäuft Kauf- oder Verkaufsinteresse auftrat.',
'significance': 'Wichtige Marken für Stop-Loss Platzierungen und Kursziele.',
'action': 'Trading an Key-Levels mit engem Risikomanagement.',
},
};
static void showPatternDetails(BuildContext context, String rawPatternType) {
final key = dictionary.keys.firstWhere(
(k) => rawPatternType.toUpperCase().contains(k) || k.contains(rawPatternType.toUpperCase()),
orElse: () => '',
);
final info = key.isNotEmpty ? dictionary[key]! : {
'title': rawPatternType,
'bias': 'NEUTRAL',
'description': 'Ein vom FinlyticAnalyzer erkanntes technisches Chart-Muster ($rawPatternType).',
'significance': 'Trendlinien und Schlüssel-Zonen zur Bestimmung von Ein- und Ausstiegssignalen.',
'action': 'Nutzen Sie Stopp-Orders und beachten Sie den übergeordneten Markt-Trend.',
};
final isBullish = info['bias'] == 'BULLISH';
final isBearish = info['bias'] == 'BEARISH';
final biasColor = isBullish ? AppTheme.primaryEmerald : (isBearish ? AppTheme.accentRed : AppTheme.accentCyan);
showDialog(
context: context,
builder: (modalContext) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
info['title']!,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: biasColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: biasColor),
),
child: Text(
info['bias']!,
style: TextStyle(color: biasColor, fontWeight: FontWeight.bold, fontSize: 11),
),
),
],
),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Formationsbeschreibung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['description']!, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
const Text('Markt-Bedeutung & Psychologie:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Text(info['significance']!, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
const SizedBox(height: 14),
const Text('Empfohlene Trading-Handlung:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Text(info['action']!, style: TextStyle(color: AppTheme.accentCyan, fontSize: 12, fontWeight: FontWeight.w600, height: 1.4)),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(modalContext),
child: const Text('Schließen', style: TextStyle(color: Colors.white)),
),
],
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../bloc/fundamentals/asset_fundamentals_event.dart';
import '../bloc/header/asset_header_bloc.dart';
import '../bloc/header/asset_header_event.dart';
import '../bloc/technical/asset_technical_bloc.dart';
import '../bloc/technical/asset_technical_event.dart';
import '../bloc/trades/asset_trades_bloc.dart';
import '../bloc/trades/asset_trades_event.dart';
import '../repositories/asset_repository.dart';
import 'layouts/asset_page_desktop_layout.dart';
import 'layouts/asset_page_mobile_layout.dart';
class AssetDetailScreen extends StatelessWidget {
final String symbol;
final ApiClient apiClient;
const AssetDetailScreen({
super.key,
required this.symbol,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final repository = AssetRepository(apiClient: apiClient);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => AssetHeaderBloc(repository: repository)..add(LoadAssetHeader(symbol)),
),
BlocProvider(
create: (context) => AssetFundamentalsBloc(repository: repository)..add(LoadAssetFundamentals(symbol)),
),
BlocProvider(
create: (context) => AssetTechnicalBloc(repository: repository)..add(LoadAssetTechnical(symbol)),
),
BlocProvider(
create: (context) => AssetTradesBloc(repository: repository)..add(LoadAssetTrades(symbol)),
),
],
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900) {
return AssetPageDesktopLayout(symbol: symbol);
}
return AssetPageMobileLayout(symbol: symbol);
},
),
),
);
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
import '../tabs/trades_tab.dart';
class AssetPageDesktopLayout extends StatefulWidget {
final String symbol;
const AssetPageDesktopLayout({super.key, required this.symbol});
@override
State<AssetPageDesktopLayout> createState() => _AssetPageDesktopLayoutState();
}
class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout> with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedExchange;
String? _selectedTicker;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: newTicker, forceRefresh: false));
final favCubit = context.read<FavoritesCubit>();
if (favCubit.state.isFavorite(widget.symbol)) {
favCubit.updateFavoriteTicker(widget.symbol, newTicker);
}
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, forceRefresh: true, exchange: _selectedExchange, ticker: _selectedTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = state.data!.symbol;
_selectedExchange = state.data!.exchange;
});
// Re-trigger fundamentals and TA with resolved ticker
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
}
}
},
child: LayoutBuilder(
builder: (context, constraints) {
final height = constraints.maxHeight.isFinite ? constraints.maxHeight : MediaQuery.of(context).size.height;
return SizedBox(
height: height,
width: double.infinity,
child: Column(
children: [
AssetHeroHeader(
symbol: widget.symbol,
selectedExchange: _selectedExchange,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left Panel (Chart Focus)
Expanded(
flex: 5,
child: Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: TechnicalTab(symbol: _selectedTicker ?? widget.symbol, isDesktopLeftPanel: true),
),
),
// Right Panel (Tabs for fundamentals/trades)
Expanded(
flex: 3,
child: Container(
margin: const EdgeInsets.only(top: 16, right: 16, bottom: 16),
decoration: BoxDecoration(
color: theme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.glassBorder),
),
child: Column(
children: [
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: theme.glassBorder,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(text: 'OVERVIEW'),
Tab(text: 'TRADES'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
FundamentalsTab(symbol: _selectedTicker ?? widget.symbol),
TradesTab(symbol: widget.symbol),
],
),
),
],
),
),
),
],
),
),
],
),
);
},
),
);
}
}
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../favorites/cubit/favorites_cubit.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_event.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../widgets/header/asset_hero_header.dart';
import '../tabs/fundamentals_tab.dart';
import '../tabs/technical_tab.dart';
import '../tabs/trades_tab.dart';
class AssetPageMobileLayout extends StatefulWidget {
final String symbol;
const AssetPageMobileLayout({super.key, required this.symbol});
@override
State<AssetPageMobileLayout> createState() => _AssetPageMobileLayoutState();
}
class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout> with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _selectedExchange;
String? _selectedTicker;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _handleExchangeChanged(String newExchange, String newTicker) {
setState(() {
_selectedExchange = newExchange;
_selectedTicker = newTicker;
});
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, exchange: newExchange, ticker: newTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: newTicker, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: newTicker, forceRefresh: false));
final favCubit = context.read<FavoritesCubit>();
if (favCubit.state.isFavorite(widget.symbol)) {
favCubit.updateFavoriteTicker(widget.symbol, newTicker);
}
}
void _handleForceRefresh() {
context.read<AssetHeaderBloc>().add(LoadAssetHeader(widget.symbol, forceRefresh: true, exchange: _selectedExchange, ticker: _selectedTicker));
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: _selectedTicker, forceRefresh: true));
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocListener<AssetHeaderBloc, AssetHeaderState>(
listener: (context, state) {
if (state is AssetHeaderLoaded && state.data != null) {
if (_selectedTicker == null) {
setState(() {
_selectedTicker = state.data!.symbol;
_selectedExchange = state.data!.exchange;
});
// Re-trigger fundamentals and TA with resolved ticker
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, ticker: state.data!.symbol, forceRefresh: false));
}
}
},
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverToBoxAdapter(
child: AssetHeroHeader(
symbol: widget.symbol,
selectedExchange: _selectedExchange,
onExchangeChanged: _handleExchangeChanged,
onForceRefresh: _handleForceRefresh,
),
),
SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
TabBar(
controller: _tabController,
labelColor: theme.primaryColor,
unselectedLabelColor: theme.textMuted,
indicatorColor: theme.primaryColor,
dividerColor: Colors.transparent,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
tabs: const [
Tab(text: 'OVERVIEW'),
Tab(text: 'TECHNICAL'),
Tab(text: 'TRADES'),
],
),
theme.cardSurface,
),
),
];
},
body: TabBarView(
controller: _tabController,
children: [
FundamentalsTab(symbol: _selectedTicker ?? widget.symbol),
TechnicalTab(symbol: _selectedTicker ?? widget.symbol),
TradesTab(symbol: widget.symbol),
],
),
),
);
}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar _tabBar;
final Color _backgroundColor;
_SliverAppBarDelegate(this._tabBar, this._backgroundColor);
@override
double get minExtent => _tabBar.preferredSize.height;
@override
double get maxExtent => _tabBar.preferredSize.height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: _backgroundColor,
child: _tabBar,
);
}
@override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return false;
}
}
@@ -0,0 +1,630 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/fundamental_data_model.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
import '../../utils/metric_explanations.dart';
class FundamentalsTab extends StatefulWidget {
final String symbol;
const FundamentalsTab({super.key, required this.symbol});
@override
State<FundamentalsTab> createState() => _FundamentalsTabState();
}
class _FundamentalsTabState extends State<FundamentalsTab> {
String _selectedPeriodType = 'Annual'; // 'Annual' or 'Quarterly'
String _selectedStatementType = 'Income'; // 'Income', 'Balance', 'CashFlow'
@override
void initState() {
super.initState();
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: false));
}
@override
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.symbol != widget.symbol) {
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: false));
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, state) {
if (state is AssetFundamentalsLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AssetFundamentalsError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetFundamentalsLoaded) {
final data = state.data;
if (data == null) {
return _buildEmptyState();
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Analyst Forecasts & Price Targets Header Card
_buildPriceTargetCard(data),
const SizedBox(height: 20),
// 2. Valuation Multiples & Ratios
_buildSectionHeader('Bewertungskennzahlen & Multiples', Icons.analytics_outlined),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_buildMetricCard('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_buildMetricCard('PEG Ratio', _fmtMultiple(data.pegRatio)),
_buildMetricCard('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_buildMetricCard('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_buildMetricCard('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_buildMetricCard('EV / Sales', _fmtMultiple(data.evToRevenue)),
_buildMetricCard('Enterprise Value', _formatNumber(data.enterpriseValue)),
_buildMetricCard('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_buildMetricCard('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_buildMetricCard('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
_buildMetricCard('Short Ratio', _fmtMultiple(data.shortRatio)),
],
),
const SizedBox(height: 24),
// 3. Profitability & Financial Health Margins
_buildSectionHeader('Rentabilität & Finanzielle Gesundheit', Icons.account_balance_outlined),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_buildMetricCard('Operative Marge', _fmtPercent(data.operatingMargin)),
_buildMetricCard('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_buildMetricCard('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_buildMetricCard('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_buildMetricCard('ROIC (Invested Capital)', _fmtPercent(data.returnOnInvestedCapital)),
_buildMetricCard('Verschuldungsgrad (D/E)', _fmtMultiple(data.debtToEquity)),
_buildMetricCard('Current Ratio', _fmtMultiple(data.currentRatio)),
_buildMetricCard('Quick Ratio', _fmtMultiple(data.quickRatio)),
_buildMetricCard('Zinsdeckungsgrad', _fmtMultiple(data.interestCoverage)),
],
),
const SizedBox(height: 24),
// 4. Dividends & Ownership
_buildSectionHeader('Dividenden & Aktionärsstruktur', Icons.pie_chart_outline),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('Dividendenrendite', _fmtPercent(data.dividendYield)),
_buildMetricCard('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_buildMetricCard('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_buildMetricCard('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_buildMetricCard('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_buildMetricCard('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_buildMetricCard('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
],
),
const SizedBox(height: 24),
// 5. Financial Statements Section
_buildSectionHeader('Finanzberichte (Statements)', Icons.article_outlined),
const SizedBox(height: 12),
_buildStatementsSection(data),
const SizedBox(height: 24),
// 6. Company Description & Detailed Executive Board
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
const SizedBox(height: 12),
_buildProfileSection(data),
],
),
);
}
return _buildEmptyState();
},
);
}
Widget _buildPriceTargetCard(FundamentalDataModel data) {
final rating = data.consensusRating ?? 'N/A';
final targetMean = data.priceTargetMean;
final targetLow = data.priceTargetLow;
final targetHigh = data.priceTargetHigh;
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
const SizedBox(width: 8),
const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
],
),
],
),
);
}
Widget _buildTargetStat(String title, String val, Color col) {
return Column(
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
],
);
}
Widget _buildStatementsSection(FundamentalDataModel data) {
// Filter statements by Jährlich / Quartal
final filteredStatements = data.financialStatements
.where((s) => s.periodType.toLowerCase() == _selectedPeriodType.toLowerCase())
.toList();
// Sort descending by date
filteredStatements.sort((a, b) => b.endDate.compareTo(a.endDate));
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row containing switches
Row(
children: [
// Period Toggle (Annual / Quarterly)
DropdownButton<String>(
value: _selectedPeriodType,
dropdownColor: AppTheme.cardSurface,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
underline: const SizedBox.shrink(),
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
items: const [
DropdownMenuItem(value: 'Annual', child: Text('Jährlich (Annual)')),
DropdownMenuItem(value: 'Quarterly', child: Text('Quartal (Quarterly)')),
],
onChanged: (val) {
if (val != null) {
setState(() => _selectedPeriodType = val);
}
},
),
const Spacer(),
// Statement Type Selector
Row(
children: [
_buildStatementTabButton('GuV', 'Income'),
const SizedBox(width: 6),
_buildStatementTabButton('Bilanz', 'Balance'),
const SizedBox(width: 6),
_buildStatementTabButton('Cashflow', 'CashFlow'),
],
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 8),
if (filteredStatements.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'Keine Berichte für diesen Typ vorhanden.',
style: TextStyle(color: AppTheme.textMuted, fontStyle: FontStyle.italic),
),
),
)
else
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Table(
defaultColumnWidth: const FixedColumnWidth(110),
columnWidths: const {
0: FixedColumnWidth(180), // First column containing label is wider
},
border: TableBorder(
horizontalInside: BorderSide(color: Colors.white, width: 0.5),
),
children: _buildTableRows(filteredStatements),
),
),
],
),
);
}
Widget _buildStatementTabButton(String label, String typeCode) {
final isSelected = _selectedStatementType == typeCode;
return InkWell(
onTap: () => setState(() => _selectedStatementType = typeCode),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : Colors.white10,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? AppTheme.primaryEmerald : Colors.white70,
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
List<TableRow> _buildTableRows(List<FinancialStatementModel> statements) {
final List<TableRow> rows = [];
// Header row containing Dates
rows.add(
TableRow(
children: [
_buildTableCell('Kennzahl (in EUR)', isHeader: true),
...statements.map((s) => _buildTableCell(_fmtDate(s.endDate), isHeader: true)),
],
),
);
if (_selectedStatementType == 'Income') {
rows.add(_buildDataRow('Umsatzerlöse', statements.map((s) => s.totalRevenue).toList()));
rows.add(_buildDataRow('Umsatzkosten', statements.map((s) => s.costOfRevenue).toList()));
rows.add(_buildDataRow('Bruttogewinn', statements.map((s) => s.grossProfit).toList()));
rows.add(_buildDataRow('Operative Aufwendungen', statements.map((s) => s.operatingExpenses).toList()));
rows.add(_buildDataRow('Operatives Ergebnis (EBIT)', statements.map((s) => s.operatingIncome).toList()));
rows.add(_buildDataRow('EBITDA', statements.map((s) => s.ebitda).toList()));
rows.add(_buildDataRow('Jahresüberschuss', statements.map((s) => s.netIncome).toList()));
rows.add(_buildDataRow('EPS (Basic)', statements.map((s) => s.epsBasic).toList(), isCurrency: true));
rows.add(_buildDataRow('EPS (Diluted)', statements.map((s) => s.epsDiluted).toList(), isCurrency: true));
} else if (_selectedStatementType == 'Balance') {
rows.add(_buildDataRow('Liquide Mittel', statements.map((s) => s.cashAndCashEquivalents).toList()));
rows.add(_buildDataRow('Forderungen', statements.map((s) => s.accountsReceivable).toList()));
rows.add(_buildDataRow('Vorräte', statements.map((s) => s.inventory).toList()));
rows.add(_buildDataRow('Umlaufvermögen (Current Assets)', statements.map((s) => s.totalCurrentAssets).toList()));
rows.add(_buildDataRow('Anlagevermögen (Non-Current)', statements.map((s) => s.totalNonCurrentAssets).toList()));
rows.add(_buildDataRow('Kurzfr. Verbindlichkeiten', statements.map((s) => s.currentLiabilities).toList()));
rows.add(_buildDataRow('Langfristige Schulden', statements.map((s) => s.longTermDebt).toList()));
rows.add(_buildDataRow('Gesamtverbindlichkeiten', statements.map((s) => s.totalLiabilities).toList()));
rows.add(_buildDataRow('Eigenkapital (Equity)', statements.map((s) => s.totalStockholdersEquity).toList()));
} else {
rows.add(_buildDataRow('Operativer Cashflow', statements.map((s) => s.operatingCashFlow).toList()));
rows.add(_buildDataRow('Investiver Cashflow', statements.map((s) => s.investingCashFlow).toList()));
rows.add(_buildDataRow('Investitionsausgaben (CapEx)', statements.map((s) => s.capitalExpenditures).toList()));
rows.add(_buildDataRow('Finanzierungs-Cashflow', statements.map((s) => s.financingCashFlow).toList()));
rows.add(_buildDataRow('Free Cashflow', statements.map((s) => s.freeCashFlow).toList()));
}
return rows;
}
TableRow _buildDataRow(String label, List<dynamic> values, {bool isCurrency = false}) {
return TableRow(
children: [
_buildTableCell(label),
...values.map((v) => _buildTableCell(isCurrency ? _fmtCurrency(v) : _formatNumber(v))),
],
);
}
Widget _buildTableCell(String val, {bool isHeader = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Text(
val,
style: TextStyle(
color: isHeader ? AppTheme.accentCyan : Colors.white70,
fontWeight: isHeader ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
);
}
Widget _buildProfileSection(FundamentalDataModel data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data.sector != null || data.industry != null || data.country != null) ...[
Row(
children: [
if (data.sector != null) ...[
_buildProfileBadge(data.sector!, Icons.category_outlined),
const SizedBox(width: 8),
],
if (data.country != null)
_buildProfileBadge(data.country!, Icons.place_outlined),
],
),
const SizedBox(height: 12),
],
Text(
data.businessSummary != null && data.businessSummary!.isNotEmpty
? data.businessSummary!
: 'Keine Beschreibung für dieses Asset verfügbar.',
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
),
if (data.employees != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
const SizedBox(width: 6),
Text(
'Mitarbeiter: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text('Führungskräfte (Board)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14)),
const SizedBox(height: 8),
...data.executives.take(5).map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.person_outline, color: AppTheme.primaryEmerald, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
Text(e.title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
if (e.compensation != null && e.compensation! > 0)
Text(
_formatNumber(e.compensation),
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12),
),
],
),
),
)),
],
],
);
}
Widget _buildProfileBadge(String label, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.insert_chart_outlined, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text('Für dieses Asset wurden noch keine Bilanz- oder Bewertungskennzahlen erfasst.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.download),
label: const Text('Daten von Backend abrufen'),
),
],
),
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
);
}
Widget _buildMetricCard(String label, String value) {
return InkWell(
onTap: () => MetricExplanations.show(context, label),
borderRadius: BorderRadius.circular(10),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
],
),
const SizedBox(height: 4),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
],
),
),
);
}
String _fmtMultiple(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(2)}x' : 'N/A';
}
String _fmtPercent(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
if (n == null) return 'N/A';
final p = (n > 0 && n <= 1) ? n * 100 : n;
return '${p.toStringAsFixed(2)}%';
}
String _fmtCurrency(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? '${n.toStringAsFixed(2)}' : 'N/A';
}
String _fmtDate(dynamic val) {
if (val == null) return 'N/A';
final dt = DateTime.tryParse(val.toString());
return dt != null ? '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}' : val.toString();
}
String _formatNumber(dynamic val) {
if (val == null) return 'N/A';
final num? n = val is num ? val : num.tryParse(val.toString());
if (n == null) return val.toString();
final isNegative = n < 0;
final absVal = n.abs();
final prefix = isNegative ? '-€' : '';
if (absVal >= 1e12) {
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bil.';
} else if (absVal >= 1e9) {
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
} else if (absVal >= 1e6) {
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
} else if (absVal >= 1e3) {
return '$prefix${(absVal / 1e3).toStringAsFixed(2)} Tsd.';
} else {
return '$prefix${absVal.toStringAsFixed(2)}';
}
}
}
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
class NewsTab extends StatelessWidget {
final String symbol;
const NewsTab({super.key, required this.symbol});
@override
Widget build(BuildContext context) {
return const Center(child: Text('News Data', style: TextStyle(color: Colors.white)));
}
}
@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../bloc/technical/asset_technical_bloc.dart';
import '../../bloc/technical/asset_technical_event.dart';
import '../../bloc/technical/asset_technical_state.dart';
import '../../utils/metric_explanations.dart';
import '../../utils/pattern_explanations.dart';
import '../../widgets/chart/candlestick_chart.dart';
class TechnicalTab extends StatefulWidget {
final String symbol;
final bool isDesktopLeftPanel;
const TechnicalTab({
super.key,
required this.symbol,
this.isDesktopLeftPanel = false,
});
@override
State<TechnicalTab> createState() => _TechnicalTabState();
}
class _TechnicalTabState extends State<TechnicalTab> {
bool _showSma50 = true;
bool _showSma200 = true;
bool _showEma = true;
bool _showPatterns = true;
bool _showSignals = true;
bool _showSupertrend = true;
// Set of disabled pattern indices for individual toggling
final Set<int> _disabledPatternIndices = {};
@override
void initState() {
super.initState();
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, forceRefresh: false));
}
@override
void didUpdateWidget(covariant TechnicalTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.symbol != widget.symbol) {
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, forceRefresh: false));
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
builder: (context, state) {
if (state is AssetTechnicalLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AssetTechnicalError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.show_chart, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
Text('Fehler beim Laden der Technischen Analyse: ${state.message}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetTechnicalLoaded) {
final data = state.data;
List<CandleModel> candles = [];
List<ChartPatternModel> patterns = [];
List<StrategySignalModel> signals = [];
List<IndicatorModel> indicators = [];
if (data != null) {
candles = data.candles.map((c) => CandleModel(time: c.timestamp, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume)).toList();
patterns = []; // Since data.patterns is a List of Strings, we don't have point coordinates to draw them on the chart
signals = data.signals.map((s) => StrategySignalModel(type: 'strategy', timestamp: s.date, direction: s.type, price: s.price, description: s.title)).toList();
indicators = data.indicators.map((i) => IndicatorModel(timestamp: i.timestamp, ema20: i.ema20, sma50: i.sma50, sma200: i.sma200, supertrendUpper: i.supertrendUpper, supertrendLower: i.supertrendLower, supertrendDirection: i.supertrendDirection)).toList();
}
// Filter patterns according to individual checkbox states
final activePatterns = [
for (int i = 0; i < patterns.length; i++)
if (!_disabledPatternIndices.contains(i)) patterns[i]
];
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Glassmorphic Indicator & Pattern Control Ribbon
GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildIndicatorChip('EMA (20)', _showEma, (v) => setState(() => _showEma = v), Colors.blueAccent),
const SizedBox(width: 6),
_buildIndicatorChip('SMA (50)', _showSma50, (v) => setState(() => _showSma50 = v), Colors.orangeAccent),
const SizedBox(width: 6),
_buildIndicatorChip('SMA (200)', _showSma200, (v) => setState(() => _showSma200 = v), Colors.redAccent),
const SizedBox(width: 6),
_buildIndicatorChip('Supertrend', _showSupertrend, (v) => setState(() => _showSupertrend = v), AppTheme.primaryEmerald),
const SizedBox(width: 6),
_buildIndicatorChip('Alle Muster', _showPatterns, (v) => setState(() => _showPatterns = v), Colors.amberAccent),
const SizedBox(width: 6),
_buildIndicatorChip('Signale', _showSignals, (v) => setState(() => _showSignals = v), AppTheme.accentCyan),
],
),
),
),
const SizedBox(height: 8),
// Interactive Candlestick Chart
SizedBox(
height: 380,
child: CandlestickChart(
candles: candles,
patterns: activePatterns,
signals: signals,
indicators: indicators,
showPatterns: _showPatterns,
showEma: _showEma,
showSma50: _showSma50,
showSma200: _showSma200,
showSignals: _showSignals,
showSupertrend: _showSupertrend,
),
),
const SizedBox(height: 16),
// Dedicated Chart Patterns & Signal Description List Section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.architecture_outlined, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
const Text('Erkannte Chart-Muster & Signale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
if (patterns.isNotEmpty)
TextButton.icon(
onPressed: () {
setState(() {
if (_disabledPatternIndices.length == patterns.length) {
_disabledPatternIndices.clear();
} else {
_disabledPatternIndices.addAll(List.generate(patterns.length, (i) => i));
}
});
},
icon: Icon(_disabledPatternIndices.isEmpty ? Icons.deselect : Icons.select_all, size: 16, color: Colors.amberAccent),
label: Text(_disabledPatternIndices.isEmpty ? 'Alle abwählen' : 'Alle anwählen', style: const TextStyle(color: Colors.amberAccent, fontSize: 12)),
),
],
),
const SizedBox(height: 12),
if (patterns.isEmpty && signals.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
)
else ...[
if (patterns.isNotEmpty) ...[
Text('Formationen & Trendlinien (Mit Checkbox im Chart schalten):', style: TextStyle(color: AppTheme.textSecondary, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 6),
...List.generate(patterns.length, (index) => _buildPatternCard(patterns[index], index)),
const SizedBox(height: 12),
],
if (signals.isNotEmpty) ...[
Text('Strategie-Signale:', style: TextStyle(color: AppTheme.textSecondary, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 6),
...signals.map((s) => _buildSignalCard(s)),
],
],
],
),
),
const SizedBox(height: 16),
],
),
);
}
return Center(
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
);
},
);
}
Widget _buildPatternCard(ChartPatternModel pattern, int index) {
final isEnabled = !_disabledPatternIndices.contains(index);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// Checkbox for individual pattern toggling on the chart
Checkbox(
value: isEnabled,
activeColor: Colors.amberAccent,
checkColor: Colors.black,
side: BorderSide(color: Colors.amberAccent.withValues(alpha: 0.6)),
onChanged: (bool? val) {
setState(() {
if (val == true) {
_disabledPatternIndices.remove(index);
} else {
_disabledPatternIndices.add(index);
}
});
},
),
Expanded(
child: InkWell(
onTap: () => PatternExplanations.showPatternDetails(context, pattern.type),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled ? Colors.amberAccent.withValues(alpha: 0.15) : AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.polyline_outlined, color: isEnabled ? Colors.amberAccent : AppTheme.textMuted, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
pattern.type,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isEnabled ? Colors.white : AppTheme.textMuted,
fontSize: 14,
decoration: isEnabled ? null : TextDecoration.lineThrough,
),
),
const SizedBox(width: 6),
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
],
),
const SizedBox(height: 4),
Text(
'Formationspunkte: Oberer Trendkanal (${pattern.upperLine.length} Pkt.) / Unterer Trendkanal (${pattern.lowerLine.length} Pkt.)',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
],
),
),
StatusBadge(
label: isEnabled ? 'AKTIV' : 'AUS',
color: isEnabled ? Colors.amberAccent : AppTheme.textMuted,
),
],
),
),
),
),
],
),
),
);
}
Widget _buildSignalCard(StrategySignalModel signal) {
final isBuy = signal.type.toUpperCase() == 'BUY';
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(signal.type.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14)),
const SizedBox(width: 8),
Text('@ €${signal.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
const SizedBox(height: 4),
Text(signal.description.isNotEmpty ? signal.description : 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
],
),
),
StatusBadge(label: 'SIGNAL', color: color),
],
),
),
);
}
Widget _buildIndicatorChip(String label, bool isSelected, ValueChanged<bool> onChanged, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
FilterChip(
selected: isSelected,
label: Text(label, style: TextStyle(color: isSelected ? Colors.black : color, fontSize: 11, fontWeight: FontWeight.bold)),
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
side: BorderSide(color: color.withValues(alpha: 0.4)),
showCheckmark: false,
onSelected: onChanged,
),
InkWell(
onTap: () => MetricExplanations.show(context, label),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
),
),
],
);
}
}
@@ -0,0 +1,915 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
import 'package:finlytic_app/features/asset_detail/models/manual_analysis_request_dto.dart';
import 'package:finlytic_app/features/trades/widgets/trade_execution_dialog.dart';
import '../../bloc/trades/asset_trades_bloc.dart';
import '../../bloc/trades/asset_trades_event.dart';
import '../../bloc/trades/asset_trades_state.dart';
class TradesTab extends StatefulWidget {
final String symbol;
const TradesTab({super.key, required this.symbol});
@override
State<TradesTab> createState() => _TradesTabState();
}
class _TradesTabState extends State<TradesTab> {
bool _justTriggeredAnalysis = false;
// Settings State
double _defaultPositionSize = 2500.0;
double _defaultLeverage = 5.0;
double _defaultRiskScore = 50.0;
double _defaultOrderFee = 1.0;
bool _autoAcceptSignals = false;
@override
void initState() {
super.initState();
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.symbol));
}
void _showLiveTradeSettingsDialog(BuildContext context) {
double tempPos = _defaultPositionSize;
double tempLev = _defaultLeverage;
double tempRisk = _defaultRiskScore;
double tempFee = _defaultOrderFee;
bool tempAuto = _autoAcceptSignals;
final posController = TextEditingController(text: tempPos.toStringAsFixed(0));
final levController = TextEditingController(text: tempLev.toStringAsFixed(1));
final feeController = TextEditingController(text: tempFee.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.settings, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('Live Trade Einstellungen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 440,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Standard Trade-Vorgaben für Ihr Depot:', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: posController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Investment (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempPos = double.tryParse(v) ?? tempPos,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: levController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Hebel (x)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempLev = double.tryParse(v) ?? tempLev,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: feeController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Standard Ordergebühr (€)', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
onChanged: (v) => tempFee = double.tryParse(v) ?? tempFee,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Standard Risiko-Toleranz:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text('${tempRisk.toInt()}/100', style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
Slider(
value: tempRisk,
min: 0,
max: 100,
divisions: 100,
activeColor: AppTheme.primaryEmerald,
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => tempRisk = val),
),
const SizedBox(height: 10),
SwitchListTile(
value: tempAuto,
activeThumbColor: AppTheme.primaryEmerald,
title: const Text('KI-Signale automatisch annehmen', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
subtitle: Text('Führt eingehende Signale direkt im Depot aus', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
onChanged: (val) => setModalState(() => tempAuto = val),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
setState(() {
_defaultPositionSize = tempPos;
_defaultLeverage = tempLev;
_defaultRiskScore = tempRisk;
_defaultOrderFee = tempFee;
_autoAcceptSignals = tempAuto;
});
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Live Trade Einstellungen gespeichert.'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.save),
label: const Text('Einstellungen Speichern'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
void _showCloseTradeDialog(BuildContext context, TradeModel trade) {
final tradesBloc = context.read<AssetTradesBloc>();
final entry = trade.actualEntryPrice > 0 ? trade.actualEntryPrice : trade.entryPrice;
final exitController = TextEditingController(text: entry.toStringAsFixed(2));
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.flag_outlined, color: AppTheme.accentRed, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('Trade Position Schließen', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Trade-ID: ${trade.id} | Asset: ${trade.symbol.isNotEmpty ? trade.symbol : widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
TextField(
controller: exitController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Tatsächlicher Ausstiegskurs (€)',
hintText: 'Z.B. 105.50',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final exitPrice = double.tryParse(exitController.text) ?? entry;
final tradeId = trade.id;
if (tradeId.isNotEmpty) {
tradesBloc.add(CloseTradeEvent(tradeId, widget.symbol, exitPrice));
}
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tradeId geschlossen zu €${exitPrice.toStringAsFixed(2)}.'),
backgroundColor: AppTheme.accentRed,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.check_circle),
label: const Text('Position Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
),
),
],
);
},
);
}
void _showAnalysisParametersDialog(BuildContext context) {
final tradesBloc = context.read<AssetTradesBloc>();
final minTimeframeController = TextEditingController(text: '1');
final maxTimeframeController = TextEditingController(text: '14');
double riskScore = _defaultRiskScore;
String timeframeUnit = 'Tage';
String instrumentType = 'Aktie / ETF (Direktinvestment)';
final notesController = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (builderContext, setModalState) {
return AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppTheme.glassBorder),
),
title: Row(
children: [
Icon(Icons.auto_awesome, color: AppTheme.accentCyan, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text('KI-Analyse Konfigurieren', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Asset / ISIN: ${widget.symbol}', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 16),
// 1. Haltedauer von - bis mit Einheit
const Text('Geplante Haltedauer:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: minTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Von', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: maxTimeframeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Bis', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: timeframeUnit,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(labelText: 'Einheit', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8)),
items: const [
DropdownMenuItem(value: 'Stunden', child: Text('Stunden')),
DropdownMenuItem(value: 'Tage', child: Text('Tage')),
DropdownMenuItem(value: 'Wochen', child: Text('Wochen')),
DropdownMenuItem(value: 'Monate', child: Text('Monate')),
],
onChanged: (val) {
if (val != null) setModalState(() => timeframeUnit = val);
},
),
),
],
),
const SizedBox(height: 16),
// 2. Risikobereitschaft 0-100 Slider
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Risikobereitschaft:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text(
'${riskScore.toInt()}/100 (${riskScore < 30 ? "Konservativ" : (riskScore < 70 ? "Ausgewogen" : "Spekulativ")})',
style: TextStyle(color: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed), fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
Slider(
value: riskScore,
min: 0,
max: 100,
divisions: 100,
activeColor: riskScore < 30 ? AppTheme.primaryEmerald : (riskScore < 70 ? Colors.orangeAccent : AppTheme.accentRed),
inactiveColor: AppTheme.glassSurface,
onChanged: (val) => setModalState(() => riskScore = val),
),
const SizedBox(height: 12),
// 3. Instrumententyp (Trade Republic typisch)
const Text('Instrumententyp (Trade Republic):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
initialValue: instrumentType,
dropdownColor: AppTheme.cardSurface,
decoration: const InputDecoration(contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10)),
items: const [
DropdownMenuItem(value: 'Aktie / ETF (Direktinvestment)', child: Text('Aktie / ETF (Direktinvestment)')),
DropdownMenuItem(value: 'Optionsschein (Warrant)', child: Text('Optionsschein (Warrant)')),
DropdownMenuItem(value: 'Knock-Out Zertifikat (Turbo)', child: Text('Knock-Out Zertifikat (Turbo)')),
DropdownMenuItem(value: 'Faktor-Zertifikat', child: Text('Faktor-Zertifikat')),
DropdownMenuItem(value: 'Krypto (Crypto)', child: Text('Krypto (Crypto)')),
],
onChanged: (val) {
if (val != null) setModalState(() => instrumentType = val);
},
),
const SizedBox(height: 16),
// 4. Anmerkung für die KI
const Text('Anmerkung für die KI:', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 6),
TextField(
controller: notesController,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Z.B. Besonderes Augenmerk auf Hebelprodukte legen, enge Stopps berücksichtigen...',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text('Abbrechen', style: TextStyle(color: AppTheme.textMuted)),
),
ElevatedButton.icon(
onPressed: () {
final payload = ManualAnalysisRequestDto(
isin: widget.symbol,
symbol: widget.symbol,
riskScore: riskScore.toInt(),
minTimeframeValue: int.tryParse(minTimeframeController.text) ?? 1,
maxTimeframeValue: int.tryParse(maxTimeframeController.text) ?? 14,
timeframeUnit: timeframeUnit,
instrumentType: instrumentType,
userNotes: notesController.text,
headline: 'Manuelle KI-Analyse für ${widget.symbol}',
);
setState(() {
_justTriggeredAnalysis = true;
});
tradesBloc.add(TriggerManualAnalysis(widget.symbol, payload: payload));
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('KI-Analyse für ${widget.symbol} gestartet. Trade-Ausführungsdialog öffnet sich in Kürze...'),
backgroundColor: AppTheme.accentCyan,
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.flash_on),
label: const Text('Analyse Jetzt Ausführen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
),
),
],
);
},
);
},
);
}
void _showEditTradeExecutionDialog(BuildContext context, TradeModel trade, {bool isActive = false}) {
final tradesBloc = context.read<AssetTradesBloc>();
TradeExecutionDialog.show(
context,
trade: trade,
defaultSymbol: widget.symbol,
isActive: isActive,
onAccept: (dto) {
tradesBloc.add(AcceptTradeEvent(dto, widget.symbol));
final tId = trade.id;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isActive
? 'Einstellungen für Trade $tId gespeichert!'
: 'Trade $tId angenommen & Position eröffnet!'),
backgroundColor: AppTheme.primaryEmerald,
behavior: SnackBarBehavior.floating,
),
);
},
onReject: (tId) {
tradesBloc.add(RejectTradeEvent(tId, widget.symbol));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trade $tId abgelehnt.'),
backgroundColor: AppTheme.textSecondary,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<AssetTradesBloc, AssetTradesState>(
listener: (context, state) {
if (_justTriggeredAnalysis && state is AssetTradesLoaded) {
final List<TradeModel> tradesList = state.data;
if (tradesList.isNotEmpty) {
_justTriggeredAnalysis = false;
final latestTrade = tradesList.first;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showEditTradeExecutionDialog(context, latestTrade);
});
}
}
},
builder: (context, state) {
final List<TradeModel> tradesList = (state is AssetTradesLoaded) ? state.data : [];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Action Button & Settings Card
GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Trade & Signal Management', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.white)),
const SizedBox(height: 4),
Text('KI-gestützte technische & fundamentale Trade-Analyse anfordern', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 8),
StatusBadge(label: widget.symbol, color: AppTheme.accentCyan),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showAnalysisParametersDialog(context),
icon: const Icon(Icons.auto_awesome, size: 18),
label: const Text('Analyse starten', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 10),
IconButton.filledTonal(
onPressed: () => _showLiveTradeSettingsDialog(context),
icon: const Icon(Icons.settings, color: Colors.white),
tooltip: 'Live Trade Einstellungen',
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(14),
),
),
],
),
],
),
),
const SizedBox(height: 20),
if (state is AssetTradesLoading)
Center(child: Padding(padding: const EdgeInsets.all(32), child: CircularProgressIndicator(color: AppTheme.primaryEmerald)))
else if (state is AssetTradesError)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Text('Fehler: ${state.message}', style: TextStyle(color: AppTheme.accentRed)),
)
else if (state is AssetTradesLoaded) ...[
_buildTradeList(
'Aktive Trade-Signale & Positionen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'ACTIVE' || s == 'PENDING' || s == 'PROPOSED';
}).toList(),
),
const SizedBox(height: 20),
_buildTradeList(
'Historische Trades & KI-Bewertungen',
tradesList.where((t) {
final s = t.status.toUpperCase();
return s == 'CLOSED' || s == 'REJECTED' || (s != 'ACTIVE' && s != 'PENDING' && s != 'PROPOSED');
}).toList(),
),
],
],
),
);
},
);
}
Widget _buildTradeList(String title, List<TradeModel> trades) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 10),
if (trades.isEmpty)
GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Keine Trades in dieser Kategorie vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: trades.length,
itemBuilder: (context, index) {
final trade = trades[index];
return _buildRichTradeCard(trade);
},
),
],
);
}
Widget _buildRichTradeCard(TradeModel trade) {
final isin = trade.isin.isNotEmpty ? trade.isin : widget.symbol;
final side = (trade.signalType.isNotEmpty ? trade.signalType : 'BUY').toUpperCase();
final status = trade.status.toUpperCase();
final isBuy = side == 'BUY' || side == 'LONG';
final isActive = status == 'ACTIVE';
final sideColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
// AI Execution Plan N8N values
final entryZoneMin = trade.entryZoneMin;
final entryZoneMax = trade.entryZoneMax;
final entryPrice = trade.entryPrice;
final stopLoss = trade.stopLoss;
final takeProfit = trade.takeProfit;
final takeProfitTargets = trade.takeProfitTargets;
final crv = (takeProfit > 0 && stopLoss > 0 && entryPrice > 0) ? ((takeProfit - entryPrice).abs() / (entryPrice - stopLoss).abs()).toStringAsFixed(2) : null;
final maxLeverage = trade.maxLeverage;
// Real User Execution Values
final actualEntry = trade.actualEntryPrice;
final posSize = trade.positionSize;
final levUsed = trade.leverageUsed;
final qty = trade.positionSize > 0 && trade.actualEntryPrice > 0 ? trade.positionSize / trade.actualEntryPrice : 0;
final entryFee = trade.entryFee;
final exitFee = trade.exitFee;
// Rationale strings
final reasoning = trade.reasoning;
final techRationale = trade.technicalRationale;
final fundRationale = trade.fundamentalRationale;
final riskWarning = trade.riskWarning;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row: Side, Status, Instrument, Action Buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
StatusBadge(label: side, color: sideColor),
const SizedBox(width: 8),
StatusBadge(label: status, color: isActive ? AppTheme.primaryEmerald : (status == 'PROPOSED' ? AppTheme.accentCyan : AppTheme.textMuted)),
const SizedBox(width: 8),
if (trade.instrumentType.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(6),
),
child: Text(trade.instrumentType, style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
Row(
children: [
if (isActive) ...[
ElevatedButton.icon(
onPressed: () => _showCloseTradeDialog(context, trade),
icon: const Icon(Icons.flag_outlined, size: 14),
label: const Text('Schließen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentRed,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () => _showEditTradeExecutionDialog(context, trade, isActive: true),
icon: const Icon(Icons.settings, size: 16, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: AppTheme.glassSurface,
padding: const EdgeInsets.all(8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
] else if (status == 'PROPOSED' || status == 'PENDING') ...[
ElevatedButton.icon(
onPressed: () => _showEditTradeExecutionDialog(context, trade),
icon: const Icon(Icons.check_circle, size: 14),
label: const Text('Trade Annehmen'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
const SizedBox(height: 12),
// Asset ID & Timeframe Subheader
Text('${trade.companyName.isNotEmpty ? trade.companyName : widget.symbol} ($isin) | Timeframe: ${trade.timeframe} | Risk: ${trade.riskTolerance}', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
const SizedBox(height: 12),
// AI Execution Targets Grid (Entry Zone, SL, TP, CRV, MaxLeverage)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Einstiegszone', entryZoneMin > 0 && entryZoneMax > 0 ? '${_fmt(entryZoneMin)} - €${_fmt(entryZoneMax)}' : '${_fmt(entryPrice)}', Colors.white),
_buildTradeStat('Stop-Loss', '${_fmt(stopLoss)}', AppTheme.accentRed),
_buildTradeStat('Take-Profit', takeProfitTargets.isNotEmpty ? takeProfitTargets.map((t) => '${_fmt(t)}').join(' / ') : '${_fmt(takeProfit)}', AppTheme.primaryEmerald),
],
),
if (crv != null || maxLeverage > 0) ...[
const Divider(color: Colors.white12, height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (crv != null) _buildTradeStat('Chance-Risiko-Verh. (CRV)', '${_fmt(crv)}x', AppTheme.accentCyan),
if (maxLeverage > 0) _buildTradeStat('Max. Hebel', '${_fmt(maxLeverage)}x', Colors.orangeAccent),
],
),
],
],
),
),
// Real User Execution Data Section (Actual Entry, Position Size, Leverage Used, Fees, Quantity)
if (actualEntry > 0 || posSize > 0 || levUsed > 0 || qty > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.person_pin_outlined, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
const Text('Ihre Tatsächlichen Ausführungsdaten:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.white)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Tatsächl. Einstieg', '${_fmt(actualEntry > 0 ? actualEntry : entryPrice)}', Colors.white),
_buildTradeStat('Investition', posSize > 0 ? '${_fmt(posSize)}' : 'N/A', Colors.white),
_buildTradeStat('Genutzter Hebel', levUsed > 0 ? '${_fmt(levUsed)}x' : '1x', AppTheme.primaryEmerald),
_buildTradeStat('Stückzahl', qty > 0 ? '${_fmt(qty)} Stk.' : 'N/A', Colors.white70),
],
),
if (entryFee > 0 || exitFee > 0) ...[
const SizedBox(height: 6),
Text('Gebühren: Einstieg €${_fmt(entryFee)} | Ausstieg €${_fmt(exitFee)}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
],
),
),
],
// Closed Trade Outcome & Performance Section
if (status == 'CLOSED' || trade.pnlAbsolute != 0) ...[
const SizedBox(height: 12),
Builder(
builder: (context) {
final pnlVal = trade.calculatedPnlAbs;
final pnlPctVal = trade.calculatedPnlPct;
final isWin = pnlVal >= 0;
final color = isWin ? AppTheme.primaryEmerald : AppTheme.accentRed;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
isWin ? Icons.trending_up : Icons.trending_down,
size: 16,
color: color,
),
const SizedBox(width: 6),
const Text('Trade Ergebnis & Realisierter PnL:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
],
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTradeStat('Ausstiegskurs', 'N/A', Colors.white),
_buildTradeStat(
'Realisierter PnL (€)',
'${(isWin ? "+€" : "-€")}${_fmt(pnlVal.abs())}',
color,
),
_buildTradeStat(
'Rendite (%)',
'${(pnlPctVal >= 0 ? "+" : "")}${_fmt(pnlPctVal)}%',
pnlPctVal >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed,
),
],
),
],
),
);
},
),
],
// AI Rationale & Warnings
if (reasoning.isNotEmpty || techRationale.isNotEmpty || fundRationale.isNotEmpty || riskWarning.isNotEmpty) ...[
const SizedBox(height: 12),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
dense: true,
title: Text('KI-Analyse & Begründung anzeigen', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 13)),
children: [
if (reasoning.isNotEmpty) ...[
_buildRationaleBlock('Gesamt-Strategie & KI-Entscheidung', reasoning, Colors.white70),
const SizedBox(height: 8),
],
if (techRationale.isNotEmpty) ...[
_buildRationaleBlock('Technische Analyse', techRationale, Colors.white70),
const SizedBox(height: 8),
],
if (fundRationale.isNotEmpty) ...[
_buildRationaleBlock('Fundamentale Analyse', fundRationale, Colors.white70),
const SizedBox(height: 8),
],
if (riskWarning.isNotEmpty)
_buildRationaleBlock('Risikowarnung', riskWarning, AppTheme.accentRed),
],
),
],
],
),
),
);
}
Widget _buildTradeStat(String title, String val, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 10)),
const SizedBox(height: 2),
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 13)),
],
);
}
Widget _buildRationaleBlock(String title, String text, Color col) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 12)),
const SizedBox(height: 2),
Text(text, style: TextStyle(color: col, fontSize: 12, height: 1.4)),
],
);
}
String _fmt(dynamic val) {
if (val == null) return 'N/A';
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
return n != null ? n.toStringAsFixed(2) : val.toString();
}
}
@@ -0,0 +1,799 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class CandleModel {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
final double volume;
CandleModel({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
required this.volume,
});
factory CandleModel.fromJson(Map<String, dynamic> json) {
return CandleModel(
time: DateTime.tryParse(json['timestamp'] ?? json['time'] ?? '') ?? DateTime.now(),
open: (json['open'] ?? 0).toDouble(),
high: (json['high'] ?? 0).toDouble(),
low: (json['low'] ?? 0).toDouble(),
close: (json['close'] ?? 0).toDouble(),
volume: (json['volume'] ?? 0).toDouble(),
);
}
}
class IndicatorModel {
final DateTime timestamp;
final double? ema20;
final double? sma50;
final double? sma200;
final double? supertrendUpper;
final double? supertrendLower;
final String? supertrendDirection;
IndicatorModel({
required this.timestamp,
this.ema20,
this.sma50,
this.sma200,
this.supertrendUpper,
this.supertrendLower,
this.supertrendDirection,
});
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
return IndicatorModel(
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
ema20: json['ema20'] != null ? (json['ema20'] as num).toDouble() : null,
sma50: json['sma50'] != null ? (json['sma50'] as num).toDouble() : null,
sma200: json['sma200'] != null ? (json['sma200'] as num).toDouble() : null,
supertrendUpper: json['supertrendUpper'] != null ? (json['supertrendUpper'] as num).toDouble() : null,
supertrendLower: json['supertrendLower'] != null ? (json['supertrendLower'] as num).toDouble() : null,
supertrendDirection: json['supertrendDirection']?.toString(),
);
}
}
class PatternPoint {
final DateTime time;
final double price;
PatternPoint(this.time, this.price);
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
}
class ChartPatternModel {
final String type;
final List<PatternPoint> upperLine;
final List<PatternPoint> lowerLine;
ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
return ChartPatternModel(
type: json['type'] ?? '',
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
);
}
}
class StrategySignalModel {
final String type;
final DateTime timestamp;
final String direction;
final double price;
final String description;
StrategySignalModel({required this.type, required this.timestamp, required this.direction, required this.price, required this.description});
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
return StrategySignalModel(
type: json['type'] ?? '',
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
direction: json['direction'] ?? '',
price: (json['price'] as num).toDouble(),
description: json['description'] ?? '',
);
}
}
class CandlestickChart extends StatefulWidget {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
const CandlestickChart({
super.key,
required this.candles,
this.patterns = const [],
this.signals = const [],
this.indicators = const [],
this.showPatterns = true,
this.showSma50 = true,
this.showSma200 = true,
this.showEma = true,
this.showSignals = true,
this.showSupertrend = true,
});
@override
State<CandlestickChart> createState() => _CandlestickChartState();
}
class _CandlestickChartState extends State<CandlestickChart> {
double _scale = 1.0;
double _panOffset = 0.0;
Offset? _tapPosition;
CandleModel? _selectedCandle;
@override
Widget build(BuildContext context) {
if (widget.candles.isEmpty) {
return const Center(child: Text('No chart data'));
}
final theme = AppTheme.activePreset;
return LayoutBuilder(
builder: (context, constraints) {
final double baseWidth = 10.0;
final double spacing = 5.0;
final double totalCandleSpace = (baseWidth + spacing) * _scale;
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
final double maxOffset = 100.0;
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
return Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
setState(() {
final double zoomFactor = pointerSignal.scrollDelta.dy > 0 ? 0.9 : 1.1;
_scale = (_scale * zoomFactor).clamp(0.2, 5.0);
});
}
},
child: GestureDetector(
onScaleUpdate: (details) {
setState(() {
_scale = (_scale * details.scale).clamp(0.2, 5.0);
_panOffset += details.focalPointDelta.dx;
_panOffset = _panOffset.clamp(minOffset, maxOffset);
if (_tapPosition != null) {
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
}
});
},
onScaleEnd: (_) => setState(() {
_tapPosition = null;
_selectedCandle = null;
}),
onTapDown: (details) {
_handleTap(details.localPosition, constraints.maxWidth);
},
child: Stack(
children: [
ClipRect(
child: CustomPaint(
size: Size.infinite,
painter: _CandlePainter(
candles: widget.candles,
patterns: widget.patterns,
signals: widget.signals,
indicators: widget.indicators,
scale: _scale,
panOffset: _panOffset,
theme: theme,
showPatterns: widget.showPatterns,
showSma50: widget.showSma50,
showSma200: widget.showSma200,
showEma: widget.showEma,
showSignals: widget.showSignals,
showSupertrend: widget.showSupertrend,
tapPosition: _tapPosition,
),
),
),
if (_selectedCandle != null) _buildTooltip(theme),
// Floating Zoom & Pan Controls (Top-Left)
Positioned(
left: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 1.25).clamp(0.2, 5.0)),
tooltip: 'Zoom In',
),
IconButton(
icon: const Icon(Icons.zoom_out, size: 18),
color: theme.primaryColor,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() => _scale = (_scale * 0.8).clamp(0.2, 5.0)),
tooltip: 'Zoom Out',
),
IconButton(
icon: const Icon(Icons.center_focus_strong, size: 18),
color: theme.textMuted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => setState(() {
_scale = 1.0;
_panOffset = 0.0;
}),
tooltip: 'Reset Zoom & Pan',
),
],
),
),
),
],
),
),
);
},
);
}
void _handleTap(Offset pos, double width) {
if (widget.candles.isEmpty) return;
// Right side is for axis, don't tap there
if (pos.dx > width - 60) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * _scale;
final double totalCandleSpace = candleWidth + (spacing * _scale);
// dx = (i * totalCandleSpace) + _panOffset;
// (dx - _panOffset) / totalCandleSpace = i;
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
if (index >= 0 && index < widget.candles.length) {
setState(() {
_tapPosition = pos;
_selectedCandle = widget.candles[index];
});
}
}
Widget _buildTooltip(ThemePreset theme) {
final candle = _selectedCandle!;
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
return Positioned(
left: 10,
top: 10,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.cardSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('L: ${candle.low.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('C: ${candle.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
Text('Vol: ${candle.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
],
),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleModel> candles;
final List<ChartPatternModel> patterns;
final List<StrategySignalModel> signals;
final List<IndicatorModel> indicators;
final double scale;
final double panOffset;
final ThemePreset theme;
final bool showPatterns;
final bool showSma50;
final bool showSma200;
final bool showEma;
final bool showSignals;
final bool showSupertrend;
final Offset? tapPosition;
final double rightPadding = 60.0; // Space for price axis
final double bottomPadding = 20.0; // Space for X-axis labels
_CandlePainter({
required this.candles,
required this.patterns,
required this.signals,
required this.indicators,
required this.scale,
required this.panOffset,
required this.theme,
required this.showPatterns,
required this.showSma50,
required this.showSma200,
required this.showEma,
required this.showSignals,
required this.showSupertrend,
this.tapPosition,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
final double chartWidth = size.width - rightPadding;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
double maxPrice = 0;
double minPrice = double.infinity;
// Find min/max in view
int firstVisibleIndex = -1;
for (int i = 0; i < candles.length; i++) {
final dx = (i * totalCandleSpace) + panOffset;
if (dx + candleWidth > 0 && dx < chartWidth) {
if (firstVisibleIndex == -1) firstVisibleIndex = i;
final c = candles[i];
if (c.high > maxPrice) maxPrice = c.high;
if (c.low < minPrice) minPrice = c.low;
}
}
if (minPrice == double.infinity || maxPrice == 0) return;
// Add 10% padding to top/bottom
final range = maxPrice - minPrice;
maxPrice += range * 0.1;
minPrice -= range * 0.1;
final paddedRange = maxPrice - minPrice;
if (paddedRange <= 0) return;
final double chartHeight = size.height - bottomPadding;
final double volumeHeight = chartHeight * 0.15; // Bottom 15% for volume
final double candleAreaHeight = chartHeight - volumeHeight;
double maxVolume = 0;
for (int i = 0; i < candles.length; i++) {
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
}
if (maxVolume == 0) maxVolume = 1;
_drawGridAndAxis(canvas, size, chartWidth, candleAreaHeight, minPrice, maxPrice, paddedRange);
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
final ema20Path = Path();
final sma50Path = Path();
final sma200Path = Path();
final supertrendPath = Path();
bool firstEma20 = true;
bool firstSma50 = true;
bool firstSma200 = true;
bool firstSupertrend = true;
// Map DateTime to X for patterns and signals
double getXForTime(DateTime t) {
int bestIndex = 0;
int minDiff = 999999999;
for (int i = 0; i < candles.length; i++) {
final diff = candles[i].time.difference(t).inSeconds.abs();
if (diff < minDiff) {
minDiff = diff;
bestIndex = i;
}
}
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
}
double getYForPrice(double price) {
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
}
// Clip to chart area so we don't draw over the axis
canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final isBullish = candle.close >= candle.open;
final dx = (i * totalCandleSpace) + panOffset;
if (dx < -candleWidth || dx > chartWidth) continue; // Culling
final yHigh = getYForPrice(candle.high);
final yLow = getYForPrice(candle.low);
final yOpen = getYForPrice(candle.open);
final yClose = getYForPrice(candle.close);
// Draw Wick
canvas.drawLine(
Offset(dx + candleWidth / 2, yHigh),
Offset(dx + candleWidth / 2, yLow),
isBullish ? paintWickBullish : paintWickBearish,
);
// Draw Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
final bodyHeight = max(bottom - top, 1.0); // minimum 1px height
canvas.drawRect(
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
isBullish ? paintBullish : paintBearish,
);
// Draw Volume
final vHeight = (candle.volume / maxVolume) * volumeHeight;
final vTop = chartHeight - vHeight;
canvas.drawRect(
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
);
// Indicators mapping by time
if (indicators.isNotEmpty) {
final cx = dx + candleWidth / 2;
IndicatorModel? match;
for (var ind in indicators) {
if (ind.timestamp.isAtSameMomentAs(candle.time) || ind.timestamp.difference(candle.time).inHours.abs() < 12) {
match = ind;
break;
}
}
if (match != null) {
if (showEma && match.ema20 != null) {
final y = getYForPrice(match.ema20!);
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
else { ema20Path.lineTo(cx, y); }
}
if (showSma50 && match.sma50 != null) {
final y = getYForPrice(match.sma50!);
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
else { sma50Path.lineTo(cx, y); }
}
if (showSma200 && match.sma200 != null) {
final y = getYForPrice(match.sma200!);
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
else { sma200Path.lineTo(cx, y); }
}
if (showSupertrend) {
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
if (stVal != null) {
final y = getYForPrice(stVal);
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
else { supertrendPath.lineTo(cx, y); }
}
}
}
}
}
if (showEma && !firstEma20) {
canvas.drawPath(ema20Path, Paint()..color = theme.primaryColor..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma50 && !firstSma50) {
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
}
if (showSma200 && !firstSma200) {
canvas.drawPath(sma200Path, Paint()..color = Colors.purpleAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showSupertrend && !firstSupertrend) {
canvas.drawPath(supertrendPath, Paint()..color = Colors.lightBlueAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
}
if (showPatterns) {
_drawPatterns(canvas, getXForTime, getYForPrice);
_drawFutureProjectionZone(canvas, size, chartWidth, candleAreaHeight, getXForTime, getYForPrice);
}
if (showSignals) {
_drawSignals(canvas, getXForTime, getYForPrice);
}
if (tapPosition != null && tapPosition!.dx < chartWidth) {
_drawCrosshair(canvas, size, chartWidth, chartHeight);
}
canvas.restore(); // Restore clip
}
void _drawFutureProjectionZone(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double Function(DateTime) getX, double Function(double) getY) {
if (candles.isEmpty) return;
final lastCandle = candles.last;
final double lastX = getX(lastCandle.time);
if (lastX < chartWidth) {
// 1. Shaded background for Future Zone (No divider line)
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
final futureBgPaint = Paint()
..color = const Color(0xFF001F3F).withValues(alpha: 0.25)
..style = PaintingStyle.fill;
canvas.drawRect(futureRect, futureBgPaint);
// Label for Future Zone
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: 'PROGNOSE (MUSTER-SCHÄTZUNG)',
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
);
textPainter.layout();
textPainter.paint(canvas, Offset(lastX + 8, 8));
// 2. Projected Ghost Candles & Target Line for active patterns
for (var pattern in patterns) {
if (pattern.lowerLine.isNotEmpty || pattern.upperLine.isNotEmpty) {
final targetPrice = pattern.lowerLine.isNotEmpty ? pattern.lowerLine.last.price : (pattern.upperLine.isNotEmpty ? pattern.upperLine.last.price : 0);
if (targetPrice > 0) {
final targetY = getY(targetPrice.toDouble());
final int numSteps = 10;
final double stepWidth = (chartWidth - lastX - 30) / numSteps;
if (stepWidth <= 0) continue;
final isBullish = targetPrice >= lastCandle.close;
final projColor = isBullish ? Colors.greenAccent : Colors.redAccent;
double currX = lastX;
double currPrice = lastCandle.close;
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
for (int k = 1; k <= numSteps; k++) {
final nextX = lastX + k * stepWidth;
final waveNoise = sin(k * 0.8) * (priceDeltaPerStep.abs() * 0.3);
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.2;
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.2;
final yOpen = getY(currPrice);
final yClose = getY(nextPrice);
final yHigh = getY(highPrice);
final yLow = getY(lowPrice);
final cWidth = max(stepWidth * 0.6, 3.0);
final cLeft = nextX - cWidth / 2;
final isStepBullish = nextPrice >= currPrice;
final stepColor = isStepBullish ? Colors.greenAccent : Colors.redAccent;
// Draw Ghost Candle Wick
canvas.drawLine(
Offset(nextX, yHigh),
Offset(nextX, yLow),
Paint()..color = stepColor.withValues(alpha: 0.4)..strokeWidth = 1.0,
);
// Draw Ghost Candle Body
final top = min(yOpen, yClose);
final bottom = max(yOpen, yClose);
canvas.drawRect(
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.0)),
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
);
currX = nextX;
currPrice = nextPrice;
}
// Target Price Badge at final step
final targetX = currX;
final targetBadgePainter = TextPainter(
text: TextSpan(
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
targetBadgePainter.layout();
final badgeRect = RRect.fromLTRBR(
targetX - targetBadgePainter.width / 2,
targetY - targetBadgePainter.height / 2 - 2,
targetX + targetBadgePainter.width / 2,
targetY + targetBadgePainter.height / 2 + 2,
const Radius.circular(6),
);
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.9));
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
}
}
}
}
}
void _drawGridAndAxis(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double minPrice, double maxPrice, double range) {
final gridPaint = Paint()
..color = theme.glassBorder
..strokeWidth = 1;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
// Y Axis
final int gridLines = 5;
for (int i = 0; i <= gridLines; i++) {
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
final price = minPrice + (i / gridLines) * range;
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
textPainter.text = TextSpan(
text: price.toStringAsFixed(2),
style: TextStyle(color: theme.textMuted, fontSize: 11),
);
textPainter.layout();
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
}
// X Axis
if (candles.isEmpty) return;
final double baseWidth = 10.0;
final double spacing = 5.0;
final double candleWidth = baseWidth * scale;
final double totalCandleSpace = candleWidth + (spacing * scale);
final int xSteps = (chartWidth / 80).floor(); // label every 80px
if (xSteps <= 0) return;
for (int i = 1; i < xSteps; i++) {
double x = i * (chartWidth / xSteps);
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
if (candleIndex >= 0 && candleIndex < candles.length) {
final t = candles[candleIndex].time;
textPainter.text = TextSpan(
text: "${t.month.toString().padLeft(2,'0')}-${t.day.toString().padLeft(2,'0')}",
style: TextStyle(color: theme.textMuted, fontSize: 10),
);
textPainter.layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
}
}
}
void _drawPatterns(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
final paint = Paint()
..color = Colors.orangeAccent
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
for (var pattern in patterns) {
void drawLine(List<PatternPoint> points) {
if (points.length < 2) return;
final path = Path();
path.moveTo(getX(points[0].time), getY(points[0].price));
for (int i = 1; i < points.length; i++) {
path.lineTo(getX(points[i].time), getY(points[i].price));
}
canvas.drawPath(path, paint);
}
drawLine(pattern.upperLine);
drawLine(pattern.lowerLine);
}
}
void _drawSignals(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
for (var signal in signals) {
final x = getX(signal.timestamp);
final y = getY(signal.price);
final isBuy = signal.direction.toUpperCase() == 'BUY';
final isSell = signal.direction.toUpperCase() == 'SELL';
if (!isBuy && !isSell) continue;
final color = isBuy ? theme.primaryColor : theme.accentRed;
final label = isBuy ? '▲ BUY' : '▼ SELL';
// Draw Pill Badge for Signal
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
final badgeWidth = textPainter.width + 12;
final badgeHeight = textPainter.height + 6;
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
final badgeRect = RRect.fromLTRBR(
x - badgeWidth / 2,
badgeY,
x + badgeWidth / 2,
badgeY + badgeHeight,
const Radius.circular(10),
);
// Pill Background
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
// Pointer Line to price point
canvas.drawLine(
Offset(x, y),
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
Paint()..color = color..strokeWidth = 1.5,
);
// Text paint
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
}
}
void _drawCrosshair(Canvas canvas, Size size, double chartWidth, double chartHeight) {
final paint = Paint()
..color = theme.textMuted.withValues(alpha: 0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// Vertical
canvas.drawLine(Offset(tapPosition!.dx, 0), Offset(tapPosition!.dx, chartHeight), paint);
// Horizontal
if (tapPosition!.dy <= chartHeight) {
canvas.drawLine(Offset(0, tapPosition!.dy), Offset(chartWidth, tapPosition!.dy), paint);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) {
return oldDelegate.scale != scale ||
oldDelegate.panOffset != panOffset ||
oldDelegate.candles != candles ||
oldDelegate.patterns != patterns ||
oldDelegate.signals != signals ||
oldDelegate.indicators != indicators ||
oldDelegate.tapPosition != tapPosition;
}
}
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/asset_logo_widget.dart';
import '../../../../shared/widgets/favorite_star_button.dart';
import '../../bloc/header/asset_header_bloc.dart';
import '../../bloc/header/asset_header_state.dart';
import '../../models/asset_model.dart';
class AssetHeroHeader extends StatelessWidget {
final String symbol;
final void Function(String exchange, String ticker)? onExchangeChanged;
final VoidCallback? onForceRefresh;
final String? selectedExchange;
const AssetHeroHeader({
super.key,
required this.symbol,
this.onExchangeChanged,
this.onForceRefresh,
this.selectedExchange,
});
@override
Widget build(BuildContext context) {
final theme = AppTheme.activePreset;
return BlocBuilder<AssetHeaderBloc, AssetHeaderState>(
builder: (context, state) {
String name = symbol;
double? price;
String currency = 'EUR';
String currentExchange = selectedExchange ?? 'XETRA';
List<AssetTickerOption> tickerOptions = [
AssetTickerOption(ticker: 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: 0.0)
];
AssetModel? asset;
if (state is AssetHeaderLoaded) {
asset = state.data;
} else if (state is AssetHeaderLoading) {
asset = state.previousData;
}
if (asset != null) {
name = asset.name.isNotEmpty ? asset.name : symbol;
currency = asset.currency.isNotEmpty ? asset.currency : 'EUR';
price = asset.currentPrice;
currentExchange = selectedExchange ?? asset.exchange;
if (asset.tickers.isNotEmpty) {
tickerOptions = asset.tickers;
}
}
final selectedOption = tickerOptions.firstWhere(
(t) => t.exchange == currentExchange,
orElse: () => tickerOptions.first,
);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
decoration: BoxDecoration(
color: theme.cardSurface,
border: Border(bottom: BorderSide(color: theme.glassBorder)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (Navigator.canPop(context)) ...[
IconButton(
tooltip: 'Zurück',
icon: Icon(Icons.arrow_back, color: theme.textPrimary),
onPressed: () => Navigator.maybePop(context),
),
const SizedBox(width: 8),
],
AssetLogoWidget(symbolOrName: symbol, size: 48),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
name,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: theme.textPrimary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 2),
SelectableText(
symbol,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
letterSpacing: 1.0,
),
),
],
),
),
],
),
),
Row(
children: [
IconButton(
tooltip: 'Force Refresh Data',
icon: Icon(Icons.refresh, color: theme.primaryColor),
onPressed: onForceRefresh,
),
const SizedBox(width: 8),
FavoriteStarButton(symbol: symbol, identifier: symbol, name: name),
],
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LIVE PRICE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
letterSpacing: 1.5,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SelectableText(
price != null && price > 0 ? price.toStringAsFixed(2) : '---',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: theme.textPrimary,
),
),
const SizedBox(width: 6),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
selectedOption.tradingCurrency.isNotEmpty ? selectedOption.tradingCurrency : currency,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
),
],
),
],
),
// Interactive Ticker & Exchange Selector Dropdown
PopupMenuButton<String>(
initialValue: selectedOption.exchange,
tooltip: 'Select Exchange & Ticker',
onSelected: (newExchange) {
if (onExchangeChanged != null) {
final opt = tickerOptions.firstWhere(
(t) => t.exchange == newExchange,
orElse: () => tickerOptions.first,
);
onExchangeChanged!(newExchange, opt.ticker);
}
},
itemBuilder: (context) {
return tickerOptions.map((opt) {
final ex = opt.exchange;
final tick = opt.ticker;
final label = '$tick ($ex)';
final isSelected = ex == currentExchange;
return PopupMenuItem<String>(
value: ex,
child: Row(
children: [
Icon(
Icons.business,
size: 16,
color: isSelected ? theme.primaryColor : theme.textMuted,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? theme.primaryColor : theme.textPrimary,
),
),
],
),
);
}).toList();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.accentColor.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.business, size: 14, color: theme.accentColor),
const SizedBox(width: 6),
Text(
'${selectedOption.ticker} (${selectedOption.exchange})',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: theme.accentColor,
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_drop_down, size: 16, color: theme.accentColor),
],
),
),
),
],
),
],
),
);
},
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
/// Modal dialog explaining key financial metric formulas and trading significance.
class MetricExplanationModal extends StatelessWidget {
final String title;
final String formula;
final String description;
final String tradingSignificance;
const MetricExplanationModal({
super.key,
required this.title,
required this.formula,
required this.description,
required this.tradingSignificance,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Kennzahl: $title'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Formel:', style: TextStyle(fontWeight: FontWeight.bold)),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: Text(formula, style: const TextStyle(fontFamily: 'monospace', color: Colors.cyanAccent)),
),
const SizedBox(height: 12),
const Text('Erklärung:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(description, style: const TextStyle(fontSize: 13)),
const SizedBox(height: 12),
const Text('Bedeutung für Trading & Bewertung:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(tradingSignificance, style: const TextStyle(fontSize: 13, color: Colors.white70)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Schließen')),
],
);
}
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
class CandleData {
final DateTime time;
final double open;
final double high;
final double low;
final double close;
CandleData({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
});
factory CandleData.fromJson(Map<String, dynamic> json) {
return CandleData(
time: json['timestamp'] != null ? DateTime.parse(json['timestamp'].toString()) : DateTime.now(),
open: (json['open'] as num? ?? 0.0).toDouble(),
high: (json['high'] as num? ?? 0.0).toDouble(),
low: (json['low'] as num? ?? 0.0).toDouble(),
close: (json['close'] as num? ?? 0.0).toDouble(),
);
}
}
class CandleChartWidget extends StatelessWidget {
final List<CandleData> candles;
final double? supportLevel;
final double? resistanceLevel;
const CandleChartWidget({
super.key,
this.candles = const [],
this.supportLevel,
this.resistanceLevel,
});
@override
Widget build(BuildContext context) {
if (candles.isEmpty) {
return Container(
color: AppTheme.cardSurface,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.show_chart, color: AppTheme.textMuted, size: 48),
const SizedBox(height: 12),
Text(
'Keine Candlestick-Daten verfgbar',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
],
),
),
);
}
return Container(
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.all(12),
child: CustomPaint(
painter: _CandlePainter(
candles: candles,
supportLevel: supportLevel,
resistanceLevel: resistanceLevel,
),
child: Container(),
),
);
}
}
class _CandlePainter extends CustomPainter {
final List<CandleData> candles;
final double? supportLevel;
final double? resistanceLevel;
_CandlePainter({
required this.candles,
this.supportLevel,
this.resistanceLevel,
});
@override
void paint(Canvas canvas, Size size) {
if (candles.isEmpty) return;
double minPrice = candles.first.low;
double maxPrice = candles.first.high;
for (var c in candles) {
if (c.low < minPrice) minPrice = c.low;
if (c.high > maxPrice) maxPrice = c.high;
}
if (supportLevel != null && supportLevel! < minPrice) minPrice = supportLevel!;
if (resistanceLevel != null && resistanceLevel! > maxPrice) maxPrice = resistanceLevel!;
final priceRange = (maxPrice - minPrice) == 0 ? 1.0 : (maxPrice - minPrice);
final padding = size.height * 0.05;
final usableHeight = size.height - (padding * 2);
double getY(double price) {
final normalized = (price - minPrice) / priceRange;
return size.height - padding - (normalized * usableHeight);
}
// Gridlines
final gridPaint = Paint()
..color = Colors.white10
..strokeWidth = 1;
for (int i = 1; i <= 4; i++) {
final y = size.height * (i / 5);
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Support Line
if (supportLevel != null) {
final supPaint = Paint()
..color = AppTheme.primaryEmerald.withValues(alpha: 0.6)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final y = getY(supportLevel!);
canvas.drawLine(Offset(0, y), Offset(size.width, y), supPaint);
}
// Resistance Line
if (resistanceLevel != null) {
final resPaint = Paint()
..color = AppTheme.accentRed.withValues(alpha: 0.6)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final y = getY(resistanceLevel!);
canvas.drawLine(Offset(0, y), Offset(size.width, y), resPaint);
}
// Candlesticks
final candleWidth = (size.width / candles.length) * 0.7;
final candleSpacing = size.width / candles.length;
for (int i = 0; i < candles.length; i++) {
final candle = candles[i];
final x = (i * candleSpacing) + (candleSpacing / 2);
final isBullish = candle.close >= candle.open;
final candleColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
final wickPaint = Paint()
..color = candleColor
..strokeWidth = 1.5;
final highY = getY(candle.high);
final lowY = getY(candle.low);
canvas.drawLine(Offset(x, highY), Offset(x, lowY), wickPaint);
final openY = getY(candle.open);
final closeY = getY(candle.close);
final topY = openY < closeY ? openY : closeY;
final bodyHeight = (openY - closeY).abs();
final bodyPaint = Paint()
..color = candleColor
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(
x - (candleWidth / 2),
topY,
candleWidth,
bodyHeight < 1 ? 1 : bodyHeight,
),
bodyPaint,
);
}
}
@override
bool shouldRepaint(covariant _CandlePainter oldDelegate) => true;
}
@@ -0,0 +1,61 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/core/services/secure_storage_service.dart';
import 'package:finlytic_app/features/auth/repositories/auth_repository.dart';
import 'auth_event.dart';
import 'auth_state.dart';
export 'auth_event.dart';
export 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository repository;
AuthBloc({
required ApiClient apiClient,
required SecureStorageService storageService,
AuthRepository? repository,
}) : repository = repository ?? AuthRepository(apiClient: apiClient, storageService: storageService),
super(AuthInitial()) {
on<CheckAuthStatus>(_onCheckAuthStatus);
on<LoginRequested>(_onLoginRequested);
on<RegisterRequested>(_onRegisterRequested);
on<LogoutRequested>(_onLogoutRequested);
}
Future<void> _onCheckAuthStatus(CheckAuthStatus event, Emitter<AuthState> emit) async {
final user = await repository.checkAuthStatus();
if (user != null) {
emit(Authenticated(user));
} else {
emit(Unauthenticated());
}
}
Future<void> _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await repository.login(event.email, event.password);
emit(Authenticated(user));
} on RequiresPasswordChangeException catch (e) {
emit(AuthRequiresPasswordChange(e.userId));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onRegisterRequested(RegisterRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await repository.register(event.email, event.password, event.fullName);
emit(Authenticated(user));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLogoutRequested(LogoutRequested event, Emitter<AuthState> emit) async {
await repository.logout();
emit(Unauthenticated());
}
}
@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class CheckAuthStatus extends AuthEvent {}
class LoginRequested extends AuthEvent {
final String email;
final String password;
const LoginRequested(this.email, this.password);
@override
List<Object?> get props => [email, password];
}
class RegisterRequested extends AuthEvent {
final String email;
final String password;
final String fullName;
const RegisterRequested(this.email, this.password, this.fullName);
@override
List<Object?> get props => [email, password, fullName];
}
class LogoutRequested extends AuthEvent {}
@@ -0,0 +1,41 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/auth/models/user_model.dart';
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class Authenticated extends AuthState {
final UserModel user;
const Authenticated(this.user);
@override
List<Object?> get props => [user];
}
class Unauthenticated extends AuthState {}
class AuthRequiresPasswordChange extends AuthState {
final String userId;
const AuthRequiresPasswordChange(this.userId);
@override
List<Object?> get props => [userId];
}
class AuthFailure extends AuthState {
final String error;
const AuthFailure(this.error);
@override
List<Object?> get props => [error];
}
@@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
/// UserModel representing authenticated user session data.
class UserModel extends Equatable {
final String userId;
final String email;
final String fullName;
final String role;
final String? token;
const UserModel({
required this.userId,
required this.email,
required this.fullName,
required this.role,
this.token,
});
bool get isAdmin => role.toLowerCase() == 'admin';
factory UserModel.fromJson(Map<String, dynamic> json, {String? token}) {
return UserModel(
userId: json['userId']?.toString() ?? json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '',
fullName: json['fullName']?.toString() ?? json['name']?.toString() ?? '',
role: json['role']?.toString() ?? 'User',
token: token ?? json['token']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'userId': userId,
'email': email,
'fullName': fullName,
'role': role,
'token': token,
};
}
@override
List<Object?> get props => [userId, email, fullName, role, token];
}
@@ -0,0 +1,78 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/core/services/secure_storage_service.dart';
import 'package:finlytic_app/features/auth/models/user_model.dart';
class AuthRepository {
final ApiClient apiClient;
final SecureStorageService storageService;
AuthRepository({
required this.apiClient,
required this.storageService,
});
Future<UserModel?> checkAuthStatus() async {
final token = await storageService.getToken();
if (token == null || token.isEmpty) {
return null;
}
try {
final response = await apiClient.get('/api/v1/user/me');
if (response.statusCode == 200 && response.data != null) {
return UserModel.fromJson(response.data, token: token);
} else {
await storageService.clearAll();
return null;
}
} catch (_) {
await storageService.clearAll();
return null;
}
}
Future<UserModel> login(String email, String password) async {
final res = await apiClient.post('/api/v1/auth/login', data: {
'email': email,
'password': password,
});
if (res.statusCode == 200 && res.data != null) {
if (res.data['requiresPasswordChange'] == true) {
throw RequiresPasswordChangeException(res.data['userId']?.toString() ?? '');
}
final token = res.data['token']?.toString() ?? '';
final user = UserModel.fromJson(res.data, token: token);
await storageService.saveToken(token);
await storageService.saveUserEmail(user.email);
return user;
} else {
throw Exception('Ungültige Anmeldedaten');
}
}
Future<UserModel> register(String email, String password, String fullName) async {
final res = await apiClient.post('/api/v1/auth/register', data: {
'email': email,
'password': password,
'fullName': fullName,
});
if (res.statusCode == 200 && res.data != null) {
final token = res.data['token']?.toString() ?? '';
final user = UserModel.fromJson(res.data, token: token);
await storageService.saveToken(token);
await storageService.saveUserEmail(user.email);
return user;
} else {
throw Exception('Registrierung fehlgeschlagen');
}
}
Future<void> logout() async {
await storageService.clearAll();
}
}
class RequiresPasswordChangeException implements Exception {
final String userId;
RequiresPasswordChangeException(this.userId);
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/core/theme/app_theme.dart';
import 'package:finlytic_app/features/auth/bloc/auth_bloc.dart';
class ChangeInitialPasswordScreen extends StatefulWidget {
final String userId;
const ChangeInitialPasswordScreen({super.key, required this.userId});
@override
State<ChangeInitialPasswordScreen> createState() => _ChangeInitialPasswordScreenState();
}
class _ChangeInitialPasswordScreenState extends State<ChangeInitialPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
void _onChangePassword() async {
if (_formKey.currentState?.validate() ?? false) {
setState(() => _isLoading = true);
try {
final apiClient = context.read<ApiClient>();
final res = await apiClient.post('/api/v1/auth/change-initial-password', data: {
'userId': widget.userId,
'newPassword': _passwordController.text,
});
if (res.statusCode == 200) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.'), backgroundColor: AppTheme.accentCyan),
);
context.read<AuthBloc>().add(LogoutRequested()); // Force re-login
}
} else {
throw Exception('Fehler beim Ändern des Passworts.');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder),
boxShadow: [
BoxShadow(
color: AppTheme.accentCyan.withValues(alpha: 0.08),
blurRadius: 24,
spreadRadius: 2,
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_reset, size: 54, color: AppTheme.accentCyan),
const SizedBox(height: 12),
const Text(
'PASSWORT ÄNDERN',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 1),
),
const SizedBox(height: 8),
Text(
'Ein Administrator hat Ihr Passwort zurückgesetzt oder diesen Account neu erstellt. Bitte vergeben Sie ein neues Passwort.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
const SizedBox(height: 24),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Neues Passwort', prefixIcon: Icon(Icons.lock)),
validator: (v) => v == null || v.length < 6 ? 'Passwort muss mindestens 6 Zeichen lang sein' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort bestätigen', prefixIcon: Icon(Icons.lock_outline)),
validator: (v) => v != _passwordController.text ? 'Passwörter stimmen nicht überein' : null,
),
const SizedBox(height: 24),
_isLoading
? CircularProgressIndicator(color: AppTheme.accentCyan)
: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _onChangePassword,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentCyan,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Passwort Speichern', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
child: Text('Abbrechen & Zurück zum Login', style: TextStyle(color: AppTheme.textSecondary)),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/auth_bloc.dart';
/// User Login Screen widget.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'admin@finlytic.com');
final _passwordController = TextEditingController(text: 'AdminDefaultPassword2026!');
void _onLogin() {
if (_formKey.currentState?.validate() ?? false) {
context.read<AuthBloc>().add(LoginRequested(
_emailController.text.trim(),
_passwordController.text.trim(),
));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder),
boxShadow: [
BoxShadow(
color: AppTheme.primaryEmerald.withValues(alpha: 0.08),
blurRadius: 24,
spreadRadius: 2,
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_graph_rounded, size: 54, color: AppTheme.primaryEmerald),
const SizedBox(height: 12),
const Text(
'FINLYTIC',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 2),
),
Text('Enterprise Financial Intelligence', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
const SizedBox(height: 28),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'E-Mail', prefixIcon: Icon(Icons.email_outlined)),
validator: (v) => v == null || !v.contains('@') ? 'Gültige E-Mail erforderlich' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
validator: (v) => v == null || v.isEmpty ? 'Passwort erforderlich' : null,
),
const SizedBox(height: 32),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) {
return CircularProgressIndicator(color: AppTheme.primaryEmerald);
}
return SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _onLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Anmelden', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
),
);
},
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/auth_bloc.dart';
/// User registration screen widget.
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
context.read<AuthBloc>().add(RegisterRequested(
_emailController.text.trim(),
_passwordController.text.trim(),
_nameController.text.trim(),
));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Konto Registrieren')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Container(
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_add_outlined, size: 48, color: AppTheme.primaryEmerald),
const SizedBox(height: 16),
const Text(
'Neues Konto erstellen',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Vollständiger Name', prefixIcon: Icon(Icons.person_outline)),
validator: (v) => v == null || v.isEmpty ? 'Name erforderlich' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'E-Mail', prefixIcon: Icon(Icons.email_outlined)),
validator: (v) => v == null || !v.contains('@') ? 'Gültige E-Mail erforderlich' : null,
),
const SizedBox(height: 14),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort', prefixIcon: Icon(Icons.lock_outline)),
validator: (v) => v == null || v.length < 6 ? 'Mind. 6 Zeichen' : null,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
),
child: const Text('Registrieren', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
/// Password reset request dialog widget.
class ForgotPasswordDialog extends StatefulWidget {
const ForgotPasswordDialog({super.key});
@override
State<ForgotPasswordDialog> createState() => _ForgotPasswordDialogState();
}
class _ForgotPasswordDialogState extends State<ForgotPasswordDialog> {
final _emailController = TextEditingController();
bool _sent = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Passwort zurücksetzen'),
content: _sent
? const Text('Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail gesendet.')
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Geben Sie Ihre E-Mail-Adresse ein, um einen Zurücksetzungslink zu erhalten:'),
const SizedBox(height: 16),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'E-Mail-Adresse',
prefixIcon: Icon(Icons.email_outlined),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(_sent ? 'Schließen' : 'Abbrechen'),
),
if (!_sent)
ElevatedButton(
onPressed: () {
if (_emailController.text.contains('@')) {
setState(() => _sent = true);
}
},
child: const Text('Link Anfordern'),
),
],
);
}
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/calendar/repositories/calendar_repository.dart';
import 'calendar_event.dart';
import 'calendar_state.dart';
export 'calendar_event.dart';
export 'calendar_state.dart';
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
final CalendarRepository repository;
CalendarBloc({required this.repository}) : super(CalendarInitial()) {
on<FetchCalendarEvents>(_onFetchEvents);
on<FilterCategoryChanged>(_onFilterCategoryChanged);
on<FilterDateSelected>(_onFilterDateSelected);
on<MonthChanged>(_onMonthChanged);
}
Future<void> _onFetchEvents(FetchCalendarEvents event, Emitter<CalendarState> emit) async {
emit(CalendarLoading());
try {
final events = await repository.fetchEvents();
emit(CalendarLoaded(
allEvents: events,
currentMonth: DateTime.now(),
));
} catch (e) {
emit(const CalendarError("Fehler beim Laden des Kalenders."));
}
}
void _onFilterCategoryChanged(FilterCategoryChanged event, Emitter<CalendarState> emit) {
if (state is CalendarLoaded) {
final current = state as CalendarLoaded;
emit(current.copyWith(selectedCategory: event.category));
}
}
void _onFilterDateSelected(FilterDateSelected event, Emitter<CalendarState> emit) {
if (state is CalendarLoaded) {
final current = state as CalendarLoaded;
if (event.date == null) {
emit(current.copyWith(clearSelectedDate: true));
} else if (current.selectedDate != null &&
current.selectedDate!.year == event.date!.year &&
current.selectedDate!.month == event.date!.month &&
current.selectedDate!.day == event.date!.day) {
emit(current.copyWith(clearSelectedDate: true));
} else {
emit(current.copyWith(selectedDate: event.date));
}
}
}
void _onMonthChanged(MonthChanged event, Emitter<CalendarState> emit) {
if (state is CalendarLoaded) {
final current = state as CalendarLoaded;
emit(current.copyWith(
currentMonth: event.newMonth,
clearSelectedDate: true,
));
}
}
}
@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
abstract class CalendarEvent extends Equatable {
const CalendarEvent();
@override
List<Object?> get props => [];
}
class FetchCalendarEvents extends CalendarEvent {}
class FilterCategoryChanged extends CalendarEvent {
final String category;
const FilterCategoryChanged(this.category);
@override
List<Object?> get props => [category];
}
class FilterDateSelected extends CalendarEvent {
final DateTime? date;
const FilterDateSelected(this.date);
@override
List<Object?> get props => [date];
}
class MonthChanged extends CalendarEvent {
final DateTime newMonth;
const MonthChanged(this.newMonth);
@override
List<Object?> get props => [newMonth];
}
@@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/calendar/models/corporate_event_model.dart';
abstract class CalendarState extends Equatable {
const CalendarState();
@override
List<Object?> get props => [];
}
class CalendarInitial extends CalendarState {}
class CalendarLoading extends CalendarState {}
class CalendarLoaded extends CalendarState {
final List<CorporateEventModel> allEvents;
final String selectedCategory;
final DateTime currentMonth;
final DateTime? selectedDate;
const CalendarLoaded({
required this.allEvents,
this.selectedCategory = 'Alle',
required this.currentMonth,
this.selectedDate,
});
List<CorporateEventModel> get filteredEvents {
return allEvents.where((e) {
if (selectedCategory != 'Alle' && e.eventType != selectedCategory) {
return false;
}
if (selectedDate != null) {
if (e.eventDate.year != selectedDate!.year ||
e.eventDate.month != selectedDate!.month ||
e.eventDate.day != selectedDate!.day) {
return false;
}
}
return true;
}).toList();
}
CalendarLoaded copyWith({
List<CorporateEventModel>? allEvents,
String? selectedCategory,
DateTime? currentMonth,
DateTime? selectedDate,
bool clearSelectedDate = false,
}) {
return CalendarLoaded(
allEvents: allEvents ?? this.allEvents,
selectedCategory: selectedCategory ?? this.selectedCategory,
currentMonth: currentMonth ?? this.currentMonth,
selectedDate: clearSelectedDate ? null : (selectedDate ?? this.selectedDate),
);
}
@override
List<Object?> get props => [allEvents, selectedCategory, currentMonth, selectedDate];
}
class CalendarError extends CalendarState {
final String message;
const CalendarError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
class CorporateEventModel extends Equatable {
final String id;
final String symbol;
final String companyName;
final String eventType;
final DateTime eventDate;
final String description;
const CorporateEventModel({
required this.id,
required this.symbol,
required this.companyName,
required this.eventType,
required this.eventDate,
required this.description,
});
factory CorporateEventModel.fromJson(Map<String, dynamic> json) {
DateTime parseDate(dynamic raw) {
if (raw == null) return DateTime(1970);
final str = raw.toString();
try {
if (str.contains('.')) {
final parts = str.split('.');
if (parts.length >= 3) {
return DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
}
}
return DateTime.parse(str);
} catch (_) {
return DateTime(1970);
}
}
return CorporateEventModel(
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
companyName: json['companyName']?.toString() ?? json['CompanyName']?.toString() ?? '',
eventType: json['eventType']?.toString() ?? json['EventType']?.toString() ?? '',
eventDate: parseDate(json['eventDate'] ?? json['EventDate']),
description: json['description']?.toString() ?? json['Description']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'symbol': symbol,
'companyName': companyName,
'eventType': eventType,
'eventDate': eventDate.toIso8601String(),
'description': description,
};
}
@override
List<Object?> get props => [id, symbol, companyName, eventType, eventDate, description];
}
@@ -0,0 +1,22 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/calendar/models/corporate_event_model.dart';
class CalendarRepository {
final ApiClient apiClient;
CalendarRepository({required this.apiClient});
Future<List<CorporateEventModel>> fetchEvents() async {
try {
final res = await apiClient.get('/api/v1/calendar');
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> data = res.data;
return data.map((json) => CorporateEventModel.fromJson(json)).toList();
}
return [];
} catch (e) {
print('Error fetching calendar events: $e');
throw Exception('Kalender-Termine konnten nicht geladen werden');
}
}
}
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../bloc/calendar_bloc.dart';
import '../repositories/calendar_repository.dart';
import '../widgets/calendar_event_tile.dart';
import '../widgets/month_calendar_widget.dart';
/// Corporate Calendar Screen featuring a compact interactive monthly calendar grid,
/// event count indicators, category filters, and tile view (Kachelansicht) for events.
class CorporateCalendarScreen extends StatelessWidget {
final ApiClient apiClient;
const CorporateCalendarScreen({super.key, required this.apiClient});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CalendarBloc(
repository: CalendarRepository(apiClient: apiClient),
)..add(FetchCalendarEvents()),
child: _CorporateCalendarScreenContent(apiClient: apiClient),
);
}
}
class _CorporateCalendarScreenContent extends StatelessWidget {
final ApiClient apiClient;
const _CorporateCalendarScreenContent({required this.apiClient});
static const List<String> categories = ['Alle', 'Earnings', 'ExDividend', 'Payout'];
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
if (state is CalendarLoading) {
return Center(
child: CircularProgressIndicator(color: AppTheme.primaryEmerald),
);
}
if (state is CalendarError) {
return Center(
child: Text(state.message, style: TextStyle(color: AppTheme.textMuted)),
);
}
if (state is CalendarLoaded) {
final filtered = state.filteredEvents;
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MonthCalendarWidget(
currentMonth: state.currentMonth,
selectedDate: state.selectedDate,
events: state.allEvents.map((e) => e.toJson()).toList(),
onDateSelected: (date) {
context.read<CalendarBloc>().add(FilterDateSelected(date));
},
onMonthChanged: (newMonth) {
context.read<CalendarBloc>().add(MonthChanged(newMonth));
},
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: categories.map((cat) {
final isSelected = state.selectedCategory == cat;
String label = 'Alle';
if (cat == 'Earnings') label = 'Quartalsergebnisse';
if (cat == 'ExDividend') label = 'Ex-Dividendentage';
if (cat == 'Payout') label = 'Zahlungstage';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: isSelected,
selectedColor: AppTheme.primaryEmerald.withValues(alpha: 0.25),
backgroundColor: AppTheme.glassSurface,
labelStyle: TextStyle(
color: isSelected ? AppTheme.primaryEmerald : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
side: BorderSide(color: isSelected ? AppTheme.primaryEmerald : AppTheme.glassBorder),
onSelected: (_) {
context.read<CalendarBloc>().add(FilterCategoryChanged(cat));
},
),
);
}).toList(),
),
),
),
if (state.selectedDate != null)
TextButton.icon(
onPressed: () {
context.read<CalendarBloc>().add(const FilterDateSelected(null));
},
icon: Icon(Icons.clear, size: 14, color: AppTheme.accentCyan),
label: Text('Alle Tage', style: TextStyle(color: AppTheme.accentCyan, fontSize: 12)),
),
],
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
state.selectedDate != null
? 'Termine am ${state.selectedDate!.day.toString().padLeft(2, '0')}.${state.selectedDate!.month.toString().padLeft(2, '0')}.${state.selectedDate!.year} (${filtered.length})'
: 'Anstehende Termine (${filtered.length})',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: AppTheme.textPrimary),
),
Text('Kachelansicht', style: TextStyle(fontSize: 11, color: AppTheme.textMuted)),
],
),
const SizedBox(height: 12),
filtered.isEmpty
? Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Center(
child: Text(
'Keine Unternehmenstermine für diesen Filter/Tag gefunden.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
),
),
)
: LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = constraints.maxWidth > 750 ? 4 : (constraints.maxWidth > 480 ? 2 : 1);
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: filtered.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemBuilder: (context, index) {
return CalendarEventTile(
event: filtered[index].toJson(),
apiClient: apiClient,
);
},
);
},
),
],
),
);
}
return const SizedBox.shrink();
},
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
/// Corporate Calendar event item widget.
class CalendarEventItem extends StatelessWidget {
final Map<String, dynamic> event;
const CalendarEventItem({super.key, required this.event});
@override
Widget build(BuildContext context) {
final symbol = event['symbol']?.toString() ?? 'ASSET';
final company = event['companyName']?.toString() ?? symbol;
final type = event['eventType']?.toString() ?? 'Earnings';
final desc = event['description']?.toString() ?? '';
final dateStr = event['eventDate']?.toString() ?? '';
Color badgeColor = AppTheme.primaryEmerald;
if (type == 'ExDividend') badgeColor = AppTheme.accentCyan;
if (type == 'Payout') badgeColor = Colors.amber;
return GlassContainer(
margin: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
type == 'Earnings' ? Icons.bar_chart : (type == 'ExDividend' ? Icons.content_cut : Icons.payments),
color: badgeColor,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('$company ($symbol)', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const Spacer(),
StatusBadge(label: type, color: badgeColor),
],
),
const SizedBox(height: 4),
Text(desc, style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
Text('Datum: $dateStr', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
),
],
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/asset_utils.dart';
import '../../../core/widgets/asset_logo_widget.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
/// Glassmorphic Event Tile widget for Corporate Calendar (Kachelansicht).
class CalendarEventTile extends StatelessWidget {
final Map<String, dynamic> event;
final ApiClient apiClient;
const CalendarEventTile({
super.key,
required this.event,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final rawSymbol = event['symbol']?.toString() ?? event['Symbol']?.toString() ?? 'ASSET';
final rawCompany = event['companyName']?.toString() ?? event['CompanyName']?.toString() ?? rawSymbol;
final displayName = AssetUtils.getAssetName(rawCompany.isNotEmpty ? rawCompany : rawSymbol);
final type = event['eventType']?.toString() ?? event['EventType']?.toString() ?? 'Earnings';
final desc = event['description']?.toString() ?? event['Description']?.toString() ?? '';
final dateStr = event['eventDate']?.toString() ?? event['EventDate']?.toString() ?? '';
String formattedDate = dateStr;
try {
final dt = DateTime.parse(dateStr);
formattedDate = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
} catch (_) {}
Color badgeColor = AppTheme.primaryEmerald;
String typeLabel = 'Quartalszahlen';
if (type == 'ExDividend') {
badgeColor = AppTheme.accentCyan;
typeLabel = 'Ex-Dividende';
} else if (type == 'Payout') {
badgeColor = Colors.amber;
typeLabel = 'Zahlungstag';
}
return GlassContainer(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: displayName,
apiClient: apiClient,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
AssetLogoWidget(symbolOrName: displayName, size: 28),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
rawSymbol,
style: TextStyle(color: AppTheme.textMuted, fontSize: 10),
),
],
),
),
],
),
),
StatusBadge(label: typeLabel, color: badgeColor),
],
),
const SizedBox(height: 6),
Text(
desc,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
Text('Termin:', style: TextStyle(color: AppTheme.textMuted, fontSize: 10.5)),
const SizedBox(width: 6),
Text(
formattedDate,
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 11),
),
],
),
],
),
);
}
}
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
/// Compact Interactive Monthly Calendar Grid with event markers and event count dots.
class MonthCalendarWidget extends StatelessWidget {
final DateTime currentMonth;
final DateTime? selectedDate;
final List<dynamic> events;
final Function(DateTime) onDateSelected;
final Function(DateTime) onMonthChanged;
const MonthCalendarWidget({
super.key,
required this.currentMonth,
required this.selectedDate,
required this.events,
required this.onDateSelected,
required this.onMonthChanged,
});
static const List<String> weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
static const List<String> monthNames = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
DateTime _parseEventDate(dynamic raw) {
if (raw == null) return DateTime(1970);
final str = raw.toString();
try {
if (str.contains('.')) {
final parts = str.split('.');
if (parts.length >= 3) {
return DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
}
}
return DateTime.parse(str);
} catch (_) {
return DateTime(1970);
}
}
List<dynamic> _getEventsForDate(DateTime date) {
return events.where((e) {
final dt = _parseEventDate(e['eventDate'] ?? e['EventDate']);
return dt.year == date.year && dt.month == date.month && dt.day == date.day;
}).toList();
}
@override
Widget build(BuildContext context) {
final firstDayOfMonth = DateTime(currentMonth.year, currentMonth.month, 1);
final daysInMonth = DateTime(currentMonth.year, currentMonth.month + 1, 0).day;
// ISO weekday: Monday = 1, Sunday = 7
final firstWeekday = firstDayOfMonth.weekday;
final leadingEmptyDays = firstWeekday - 1;
final totalCells = ((leadingEmptyDays + daysInMonth) / 7).ceil() * 7;
return Center(
child: Container(
child: GlassContainer(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Month Header Navigation
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.chevron_left, color: AppTheme.accentCyan, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => onMonthChanged(DateTime(currentMonth.year, currentMonth.month - 1, 1)),
),
Text(
'${monthNames[currentMonth.month - 1]} ${currentMonth.year}',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppTheme.textPrimary),
),
IconButton(
icon: Icon(Icons.chevron_right, color: AppTheme.accentCyan, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => onMonthChanged(DateTime(currentMonth.year, currentMonth.month + 1, 1)),
),
],
),
const SizedBox(height: 6),
// Weekday Labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: weekDays.map((w) => Expanded(
child: Center(
child: Text(
w,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold),
),
),
)).toList(),
),
const SizedBox(height: 4),
// Days Grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: totalCells,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 2,
),
itemBuilder: (context, index) {
final dayNumber = index - leadingEmptyDays + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const SizedBox();
}
final cellDate = DateTime(currentMonth.year, currentMonth.month, dayNumber);
final isSelected = selectedDate != null &&
selectedDate!.year == cellDate.year &&
selectedDate!.month == cellDate.month &&
selectedDate!.day == cellDate.day;
final isToday = DateTime.now().year == cellDate.year &&
DateTime.now().month == cellDate.month &&
DateTime.now().day == cellDate.day;
final dayEvents = _getEventsForDate(cellDate);
final eventCount = dayEvents.length;
return InkWell(
onTap: () => onDateSelected(cellDate),
borderRadius: BorderRadius.circular(6),
child: Container(
margin: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
color: isSelected
? AppTheme.primaryEmerald.withValues(alpha: 0.3)
: (isToday ? AppTheme.glassSurface : Colors.transparent),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSelected
? AppTheme.primaryEmerald
: (eventCount > 0 ? AppTheme.accentCyan.withValues(alpha: 0.6) : Colors.transparent),
width: isSelected ? 1.5 : 1.0,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$dayNumber',
style: TextStyle(
fontSize: 11.5,
fontWeight: isSelected || isToday || eventCount > 0 ? FontWeight.bold : FontWeight.normal,
color: isSelected
? AppTheme.primaryEmerald
: (eventCount > 0 ? AppTheme.textPrimary : AppTheme.textSecondary),
),
),
if (eventCount > 0) ...[
const SizedBox(height: 1),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
eventCount > 4 ? 4 : eventCount,
(i) => Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
width: 4,
height: 4,
decoration: BoxDecoration(
color: AppTheme.accentCyan,
shape: BoxShape.circle,
),
),
),
),
],
],
),
),
);
},
),
],
),
),
),
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../widgets/asset_discovery_bar.dart';
import '../widgets/daily_news_snapshot.dart';
import '../widgets/favorites_carousel.dart';
import '../widgets/trades_stream_widget.dart';
/// Dashboard Tab screen assembling user favorites, discovery bar, news snapshot, and trades stream.
class DashboardScreen extends StatelessWidget {
final ApiClient apiClient;
final SignalRService signalRService;
const DashboardScreen({
super.key,
required this.apiClient,
required this.signalRService,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Asset Entdeckung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
AssetDiscoveryBar(apiClient: apiClient),
const SizedBox(height: 20),
const Text('Meine Favoriten Watchlist', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
FavoritesCarousel(apiClient: apiClient),
const SizedBox(height: 24),
TradesStreamWidget(apiClient: apiClient),
const SizedBox(height: 24),
DailyNewsSnapshot(apiClient: apiClient),
],
),
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../../discovery/cubit/discovery_cubit.dart';
import '../../favorites/cubit/favorites_cubit.dart';
/// Dynamic Scrollable Discovery Bar consuming DiscoveryCubit with real-time backend recommendations.
class AssetDiscoveryBar extends StatefulWidget {
final ApiClient apiClient;
const AssetDiscoveryBar({
super.key,
required this.apiClient,
});
@override
State<AssetDiscoveryBar> createState() => _AssetDiscoveryBarState();
}
class _AssetDiscoveryBarState extends State<AssetDiscoveryBar> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final cubit = context.read<DiscoveryCubit>();
if (cubit.state.assets.isEmpty) {
cubit.loadDiscovery();
}
}
});
}
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
return BlocBuilder<DiscoveryCubit, DiscoveryState>(
builder: (context, discState) {
if (discState.isLoading && discState.assets.isEmpty) {
return SizedBox(
height: 38,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 6,
itemBuilder: (_, __) => const Padding(
padding: EdgeInsets.only(right: 8),
child: ShimmerLoading(width: 130, height: 38, borderRadius: 20),
),
),
);
}
final assets = discState.assets;
if (assets.isEmpty) {
return const SizedBox.shrink();
}
return BlocBuilder<FavoritesCubit, FavoritesState>(
builder: (context, favState) {
return SizedBox(
height: 38,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: assets.length,
itemBuilder: (context, index) {
final asset = assets[index];
final identifier = asset.symbol.isNotEmpty ? asset.symbol : asset.isin;
final isFav = favState.isFavorite(identifier) || favState.isFavorite(asset.isin);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: Icon(
isFav ? Icons.star_rounded : Icons.explore_outlined,
size: 16,
color: isFav ? Colors.amber : activeTheme.primaryColor,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
asset.name.isNotEmpty ? asset.name : asset.symbol,
style: TextStyle(
fontSize: 12,
color: activeTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
backgroundColor: activeTheme.glassSurface,
side: BorderSide(
color: isFav ? Colors.amber.withValues(alpha: 0.5) : activeTheme.glassBorder,
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: identifier,
apiClient: widget.apiClient,
),
),
);
},
),
);
},
),
);
},
);
},
);
}
}
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/time_utils.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/status_badge.dart';
import '../../news/bloc/news_bloc.dart';
import '../../news/bloc/news_event.dart';
import '../../news/bloc/news_state.dart';
import '../../news/models/news_article_model.dart';
import '../../news/repositories/news_repository.dart';
import '../../news/widgets/article_sentiment_dialog.dart';
class DailyNewsSnapshot extends StatelessWidget {
final ApiClient apiClient;
final String backendUrl;
const DailyNewsSnapshot({
super.key,
required this.apiClient,
this.backendUrl = 'http://localhost:5000',
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => NewsBloc(
repository: NewsRepository(apiClient: apiClient, backendUrl: backendUrl),
)..add(FetchNews(date: DateTime.now().toIso8601String().substring(0, 10))),
child: const _DailyNewsSnapshotContent(),
);
}
}
class _DailyNewsSnapshotContent extends StatefulWidget {
const _DailyNewsSnapshotContent();
@override
State<_DailyNewsSnapshotContent> createState() => _DailyNewsSnapshotContentState();
}
class _DailyNewsSnapshotContentState extends State<_DailyNewsSnapshotContent> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 80) {
context.read<NewsBloc>().add(LoadMoreNews());
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Text('Tagesnachrichten', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(width: 8),
BlocBuilder<NewsBloc, NewsState>(
builder: (context, state) {
if (state is NewsLoaded && state.articles.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${state.articles.length} Artikel',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold),
),
);
}
return const SizedBox.shrink();
},
),
],
),
IconButton(
icon: Icon(Icons.refresh, color: AppTheme.accentCyan, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
context.read<NewsBloc>().add(FetchNews(isRefresh: true, date: DateTime.now().toIso8601String().substring(0, 10)));
},
),
],
),
const SizedBox(height: 12),
BlocBuilder<NewsBloc, NewsState>(
builder: (context, state) {
if (state is NewsInitial || (state is NewsLoading && context.read<NewsBloc>().state is! NewsLoaded)) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
),
);
}
if (state is NewsError) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
state.message,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
),
);
}
if (state is NewsLoaded) {
final articles = state.articles;
if (articles.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'Keine aktuellen Nachrichten verfügbar',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
),
);
}
return SizedBox(
height: 380,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: articles.length + (state.hasReachedMax ? 0 : 1),
itemBuilder: (context, index) {
if (index == articles.length) {
return Padding(
padding: const EdgeInsets.all(12),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
),
);
}
final NewsArticleModel article = articles[index];
final timeStr = TimeUtils.formatRelativeTime(article.publishedAt.toIso8601String());
final Widget? badgeWidget = article.sentiment.trim().isNotEmpty
? StatusBadge.sentiment(article.sentiment, score: article.sentimentScore)
: null;
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
article.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${article.author}${timeStr.isNotEmpty ? '$timeStr' : ''}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
),
trailing: badgeWidget,
onTap: () {
showDialog(
context: context,
builder: (_) => ArticleSentimentDialog(
articleData: article,
),
);
},
);
},
),
);
}
return const SizedBox.shrink();
},
),
],
),
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/asset_logo_widget.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../shared/widgets/favorite_star_button.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../../favorites/cubit/favorites_cubit.dart';
/// Dynamic User Favorites Carousel widget bound to FavoritesCubit with real-time SignalR prices.
class FavoritesCarousel extends StatelessWidget {
final ApiClient apiClient;
const FavoritesCarousel({
super.key,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<FavoritesCubit, FavoritesState>(
builder: (context, state) {
final activeTheme = AppTheme.activePreset;
final favoritesList = state.favoriteDetails;
if (favoritesList.isEmpty) {
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Center(
child: Text(
'Keine Favoriten vorhanden. Nutze die Suche (Lupe), um Wertpapiere hinzuzufügen.',
style: TextStyle(color: activeTheme.textMuted, fontSize: 13),
),
),
);
}
return SizedBox(
height: 110,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: favoritesList.length,
itemBuilder: (context, index) {
final fav = favoritesList[index];
final displayName = fav.name.isNotEmpty
? fav.name
: (fav.symbol.isNotEmpty ? fav.symbol : fav.isin);
final isinOrSymbol = fav.isin.isNotEmpty
? fav.isin
: (fav.symbol.isNotEmpty ? fav.symbol : fav.name);
final isPositive = fav.change24h >= 0;
return Container(
width: 195,
margin: const EdgeInsets.only(right: 12),
child: GlassContainer(
padding: const EdgeInsets.all(12),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: isinOrSymbol,
apiClient: apiClient,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
AssetLogoWidget(
symbolOrName: isinOrSymbol,
imageUrl: fav.image.isNotEmpty ? fav.image : null,
size: 24,
),
const SizedBox(width: 8),
Expanded(
child: Text(
displayName,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: activeTheme.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
FavoriteStarButton(
identifier: isinOrSymbol,
symbol: fav.symbol,
name: fav.name,
size: 18,
padding: EdgeInsets.zero,
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${fav.currentPrice > 0 ? fav.currentPrice.toStringAsFixed(2) : '--.--'}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12.5,
color: activeTheme.textPrimary,
),
),
Text(
'${isPositive ? '+' : ''}${fav.change24h.toStringAsFixed(2)}%',
style: TextStyle(
color: isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed,
fontWeight: FontWeight.bold,
fontSize: 11.5,
),
),
],
),
],
),
),
);
},
),
);
},
);
}
}
@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../trades/bloc/trade_bloc.dart';
import '../../trades/bloc/trade_event.dart';
import '../../trades/bloc/trade_state.dart';
import '../../trades/repositories/trade_repository.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import 'dart:ui';
/// Premium Dashboard Widget for Auto-Screener Asset Recommendations
class TradesStreamWidget extends StatelessWidget {
final ApiClient apiClient;
const TradesStreamWidget({super.key, required this.apiClient});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => TradeBloc(
repository: TradeRepository(apiClient: apiClient),
)..add(const FetchTrades()),
child: const _TradesStreamWidgetContent(),
);
}
}
class _TradesStreamWidgetContent extends StatelessWidget {
const _TradesStreamWidgetContent();
@override
Widget build(BuildContext context) {
return BlocBuilder<TradeBloc, TradeState>(
builder: (context, state) {
if (state is TradeLoading) {
return const _PremiumLoadingSkeleton();
}
if (state is TradeLoaded) {
final proposals = state.trades.where((t) => t.isProposed && !t.isRejected).toList();
if (proposals.isEmpty) {
return const _EmptyRecommendations();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryEmerald.withValues(alpha: 0.2),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppTheme.primaryEmerald.withValues(alpha: 0.4),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: Icon(Icons.bolt, color: AppTheme.primaryEmerald, size: 18),
),
const SizedBox(width: 8),
const Text(
'KI Asset Empfehlungen',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.white, letterSpacing: 0.5),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.star_rounded, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'${proposals.length} Neu',
style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold, fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
itemCount: proposals.length,
itemBuilder: (context, index) {
final p = proposals[index];
final isBuy = p.signalType.toUpperCase() == 'BUY' || p.signalType.toUpperCase() == 'LONG';
final signalColor = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => AssetDetailScreen(
symbol: p.symbol.isNotEmpty ? p.symbol : p.isin,
apiClient: context.read<TradeBloc>().repository.apiClient,
),
),
);
},
child: Container(
width: 260,
margin: const EdgeInsets.only(right: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.08),
signalColor.withValues(alpha: 0.02),
],
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
p.symbol.isNotEmpty ? p.symbol : 'Trade',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.white),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: signalColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: signalColor.withValues(alpha: 0.3)),
),
child: Text(
p.signalType,
style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11),
),
),
],
),
if (p.companyName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
p.companyName,
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const Spacer(),
if (p.reasoning.isNotEmpty) ...[
Text(
p.reasoning,
style: const TextStyle(color: Colors.white70, fontSize: 11, fontStyle: FontStyle.italic),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
],
Row(
children: [
Text(
'KI Score: ${(p.winRate).toStringAsFixed(0)}%',
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.w600, fontSize: 13),
),
const Spacer(),
const Icon(Icons.arrow_forward_rounded, color: Colors.white54, size: 16),
],
),
],
),
),
),
),
),
);
},
),
),
],
);
}
return const SizedBox.shrink();
},
);
}
}
class _PremiumLoadingSkeleton extends StatelessWidget {
const _PremiumLoadingSkeleton();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: 200, height: 24, borderRadius: 4),
const SizedBox(height: 12),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 3,
itemBuilder: (_, __) => const Padding(
padding: EdgeInsets.only(right: 16),
child: ShimmerLoading(width: 260, height: 160, borderRadius: 20),
),
),
),
],
);
}
}
class _EmptyRecommendations extends StatelessWidget {
const _EmptyRecommendations();
@override
Widget build(BuildContext context) {
return GlassContainer(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(Icons.radar, color: AppTheme.accentCyan, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'KI Screener läuft...',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.white),
),
const SizedBox(height: 4),
Text(
'Es gibt aktuell keine neuen hoch-konfidenten Asset-Vorschläge. Die KI analysiert den Markt kontinuierlich.',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../models/discovery_asset_model.dart';
class DiscoveryState extends Equatable {
final List<DiscoveryAssetModel> assets;
final bool isLoading;
const DiscoveryState({
this.assets = const [],
this.isLoading = false,
});
DiscoveryState copyWith({
List<DiscoveryAssetModel>? assets,
bool? isLoading,
}) {
return DiscoveryState(
assets: assets ?? this.assets,
isLoading: isLoading ?? this.isLoading,
);
}
@override
List<Object?> get props => [assets, isLoading];
}
class DiscoveryCubit extends Cubit<DiscoveryState> {
final ApiClient apiClient;
DiscoveryCubit({required this.apiClient}) : super(const DiscoveryState());
Future<void> loadDiscovery({int limit = 15}) async {
if (state.isLoading) return;
emit(state.copyWith(isLoading: true));
try {
final res = await apiClient.get('/api/v1/assets/discovery?limit=$limit');
if (res.statusCode == 200 && res.data is List) {
final list = (res.data as List)
.map((e) => DiscoveryAssetModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
emit(DiscoveryState(assets: list, isLoading: false));
} else {
emit(state.copyWith(isLoading: false));
}
} catch (_) {
emit(state.copyWith(isLoading: false));
}
}
}
@@ -0,0 +1,33 @@
class DiscoveryAssetModel {
final String isin;
final String symbol;
final String name;
final String type;
final String category;
final String? image;
final List<String> tags;
const DiscoveryAssetModel({
required this.isin,
required this.symbol,
required this.name,
required this.type,
required this.category,
this.image,
this.tags = const [],
});
factory DiscoveryAssetModel.fromJson(Map<String, dynamic> json) {
return DiscoveryAssetModel(
isin: json['isin'] ?? '',
symbol: (json['symbol'] != null && json['symbol'].toString().isNotEmpty)
? json['symbol'].toString()
: (json['isin'] ?? ''),
name: json['name'] ?? '',
type: json['type'] ?? 'stock',
category: json['category'] ?? '',
image: json['image'],
tags: (json['tags'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
);
}
}
@@ -0,0 +1,23 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/favorites/repositories/favorites_repository.dart';
import 'favorites_event.dart';
import 'favorites_state.dart';
class FavoritesBloc extends Bloc<FavoritesEvent, FavoritesState> {
final FavoritesRepository repository;
FavoritesBloc({required this.repository}) : super(FavoritesInitial()) {
on<LoadFavorites>(_onLoadFavorites);
}
Future<void> _onLoadFavorites(LoadFavorites event, Emitter<FavoritesState> emit) async {
emit(FavoritesLoading());
try {
final favorites = await repository.fetchFavoritesDetails(event.symbols);
emit(FavoritesLoaded(favorites));
} catch (e) {
emit(const FavoritesError("Fehler beim Laden der Favoriten."));
}
}
}
@@ -0,0 +1,17 @@
import 'package:equatable/equatable.dart';
abstract class FavoritesEvent extends Equatable {
const FavoritesEvent();
@override
List<Object?> get props => [];
}
class LoadFavorites extends FavoritesEvent {
final List<String> symbols;
const LoadFavorites(this.symbols);
@override
List<Object?> get props => [symbols];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/favorites/models/favorite_asset_model.dart';
abstract class FavoritesState extends Equatable {
const FavoritesState();
@override
List<Object?> get props => [];
}
class FavoritesInitial extends FavoritesState {}
class FavoritesLoading extends FavoritesState {}
class FavoritesLoaded extends FavoritesState {
final List<FavoriteAssetModel> favorites;
const FavoritesLoaded(this.favorites);
@override
List<Object?> get props => [favorites];
}
class FavoritesError extends FavoritesState {
final String message;
const FavoritesError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/signalr_service.dart';
import '../../../core/utils/asset_utils.dart';
import '../models/favorite_asset_model.dart';
class FavoritesState extends Equatable {
final Set<String> favoriteIsins;
final List<FavoriteAssetModel> favoriteDetails;
final bool isLoading;
const FavoritesState({
this.favoriteIsins = const {},
this.favoriteDetails = const [],
this.isLoading = false,
});
bool isFavorite(String identifier) {
if (identifier.isEmpty) return false;
final upper = identifier.toUpperCase();
return favoriteIsins.contains(upper);
}
FavoritesState copyWith({
Set<String>? favoriteIsins,
List<FavoriteAssetModel>? favoriteDetails,
bool? isLoading,
}) {
return FavoritesState(
favoriteIsins: favoriteIsins ?? this.favoriteIsins,
favoriteDetails: favoriteDetails ?? this.favoriteDetails,
isLoading: isLoading ?? this.isLoading,
);
}
@override
List<Object?> get props => [favoriteIsins, favoriteDetails, isLoading];
}
class FavoritesCubit extends Cubit<FavoritesState> {
final ApiClient apiClient;
final SignalRService? signalRService;
StreamSubscription<Map<String, dynamic>>? _priceSub;
FavoritesCubit({required this.apiClient, this.signalRService}) : super(const FavoritesState()) {
// 1. Subscribe to SignalR 10-second WebSocket price stream
if (signalRService != null) {
_priceSub = signalRService!.favoritePricesStream.listen((priceMap) {
updatePrices(priceMap);
});
}
}
@override
Future<void> close() {
_priceSub?.cancel();
return super.close();
}
/// Loads favorite metadata via REST ONLY WHEN NEEDED (e.g. initial load or list mutation).
Future<void> loadFavorites() async {
emit(state.copyWith(isLoading: true));
try {
final res = await apiClient.get('/api/v1/user/favorites');
if (res.statusCode == 200 && res.data is List) {
final rawList = res.data as List;
final set = <String>{};
final dedupMap = <String, FavoriteAssetModel>{};
for (var item in rawList) {
final model = FavoriteAssetModel.fromJson(Map<String, dynamic>.from(item));
AssetUtils.registerAsset(model.isin, model.name, model.image);
final key = (model.isin.isNotEmpty ? model.isin : (model.symbol.isNotEmpty ? model.symbol : model.name)).toUpperCase();
if (!dedupMap.containsKey(key)) {
dedupMap[key] = model;
}
if (model.isin.isNotEmpty) set.add(model.isin.toUpperCase());
if (model.symbol.isNotEmpty) set.add(model.symbol.toUpperCase());
if (model.name.isNotEmpty) set.add(model.name.toUpperCase());
}
emit(FavoritesState(
favoriteIsins: set,
favoriteDetails: dedupMap.values.toList(),
isLoading: false,
));
} else {
emit(state.copyWith(isLoading: false));
}
} catch (_) {
emit(state.copyWith(isLoading: false));
}
}
/// Updates current prices and daily growth % live in-memory when SignalR WebSocket payload arrives.
void updatePrices(Map<String, dynamic> priceMap) {
if (priceMap.isEmpty) return;
final updatedDetails = state.favoriteDetails.map((model) {
final keyIsin = model.isin.toUpperCase();
final keySymbol = model.symbol.toUpperCase();
dynamic priceData = priceMap[keyIsin] ?? priceMap[keySymbol];
if (priceData != null && priceData is Map) {
final double price = (priceData['currentPrice'] ?? priceData['price'] ?? model.currentPrice).toDouble();
final double change = (priceData['dailyChangePercent'] ?? priceData['change24h'] ?? model.change24h).toDouble();
return model.copyWith(currentPrice: price, change24h: change);
}
return model;
}).toList();
emit(state.copyWith(favoriteDetails: updatedDetails));
}
Future<void> toggleFavorite(String identifier, {String? symbol, String? name}) async {
if (identifier.isEmpty) return;
final target = identifier.toUpperCase();
final isCurrentlyFav = state.isFavorite(target);
// Optimistic UI update
final newSet = Set<String>.from(state.favoriteIsins);
if (isCurrentlyFav) {
newSet.remove(target);
if (symbol != null) newSet.remove(symbol.toUpperCase());
if (name != null) newSet.remove(name.toUpperCase());
} else {
newSet.add(target);
if (symbol != null) newSet.add(symbol.toUpperCase());
if (name != null) newSet.add(name.toUpperCase());
}
emit(state.copyWith(favoriteIsins: newSet));
// Perform API call in background
try {
if (isCurrentlyFav) {
await apiClient.delete('/api/v1/user/favorites/$target');
} else {
await apiClient.post('/api/v1/user/favorites/$target');
}
// Re-sync full list to ensure metadata details are fresh
await loadFavorites();
} catch (_) {
// Revert on error
await loadFavorites();
}
}
Future<void> updateFavoriteTicker(String symbol, String ticker) async {
try {
await apiClient.post('/api/v1/user/favorites/$symbol/ticker?ticker=$ticker');
await loadFavorites();
} catch (_) {
// Ignore gracefully
}
}
}
@@ -0,0 +1,62 @@
import 'package:equatable/equatable.dart';
class FavoriteAssetModel extends Equatable {
final String symbol;
final String name;
final String isin;
final String image;
final double currentPrice;
final double change24h;
const FavoriteAssetModel({
required this.symbol,
required this.name,
this.isin = '',
this.image = '',
required this.currentPrice,
required this.change24h,
});
factory FavoriteAssetModel.fromJson(Map<String, dynamic> json) {
return FavoriteAssetModel(
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
name: json['name']?.toString() ?? json['Name']?.toString() ?? '',
isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? json['symbol']?.toString() ?? '',
image: json['image']?.toString() ?? json['Image']?.toString() ?? '',
currentPrice: (json['currentPrice'] ?? json['CurrentPrice'] ?? json['price'] ?? 0.0).toDouble(),
change24h: (json['dailyChangePercent'] ?? json['DailyChangePercent'] ?? json['change24h'] ?? json['Change24h'] ?? 0.0).toDouble(),
);
}
FavoriteAssetModel copyWith({
String? symbol,
String? name,
String? isin,
String? image,
double? currentPrice,
double? change24h,
}) {
return FavoriteAssetModel(
symbol: symbol ?? this.symbol,
name: name ?? this.name,
isin: isin ?? this.isin,
image: image ?? this.image,
currentPrice: currentPrice ?? this.currentPrice,
change24h: change24h ?? this.change24h,
);
}
Map<String, dynamic> toJson() {
return {
'symbol': symbol,
'name': name,
'isin': isin,
'image': image,
'currentPrice': currentPrice,
'change24h': change24h,
};
}
@override
List<Object?> get props => [symbol, name, isin, image, currentPrice, change24h];
}
@@ -0,0 +1,35 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/favorites/models/favorite_asset_model.dart';
import 'package:finlytic_app/core/utils/asset_utils.dart';
class FavoritesRepository {
final ApiClient apiClient;
FavoritesRepository({required this.apiClient});
Future<List<FavoriteAssetModel>> fetchFavoritesDetails(List<String> symbols) async {
if (symbols.isEmpty) return [];
try {
final res = await apiClient.post('/api/v1/assets/batch', data: {'symbols': symbols});
if (res.statusCode == 200 && res.data != null) {
final List<dynamic> data = res.data;
return data.map((json) => FavoriteAssetModel.fromJson(json)).toList();
}
return symbols.map((s) => FavoriteAssetModel(
symbol: s,
name: AssetUtils.getAssetName(s),
currentPrice: 0.0,
change24h: 0.0,
)).toList();
} catch (e) {
print('Error fetching favorites details: $e');
return symbols.map((s) => FavoriteAssetModel(
symbol: s,
name: AssetUtils.getAssetName(s),
currentPrice: 0.0,
change24h: 0.0,
)).toList();
}
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../cubit/favorites_cubit.dart';
import '../widgets/watchlist_card.dart';
/// User-bound Watchlist Grid screen with multi-device sync and real-time status.
class FavoritesScreen extends StatelessWidget {
final ApiClient apiClient;
const FavoritesScreen({
super.key,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Meine Watchlist & Favoriten', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: activeTheme.textPrimary)),
const SizedBox(height: 4),
Text('Synchronisiert über alle Geräte hinweg.', style: TextStyle(color: activeTheme.textMuted, fontSize: 13)),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<FavoritesCubit, FavoritesState>(
builder: (context, state) {
if (state.isLoading && state.favoriteDetails.isEmpty) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
mainAxisExtent: 140,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: 6,
itemBuilder: (context, index) => ShimmerLoading(width: 260, height: 140),
);
}
final favorites = state.favoriteDetails;
if (favorites.isEmpty) {
return Center(
child: Text(
'Noch keine Favoriten gespeichert.\nFüge Wertpapiere über die Suchleiste oder Asset-Karten hinzu.',
textAlign: TextAlign.center,
style: TextStyle(color: activeTheme.textMuted, height: 1.4),
),
);
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
mainAxisExtent: 140,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: favorites.length,
itemBuilder: (context, index) {
final asset = favorites[index];
return WatchlistCard(
asset: asset,
apiClient: apiClient,
);
},
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/asset_utils.dart';
import '../../../core/widgets/asset_logo_widget.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../shared/widgets/favorite_star_button.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../models/favorite_asset_model.dart';
/// Watchlist Card item widget for favorited assets displaying brand logo and FavoriteStarButton.
class WatchlistCard extends StatelessWidget {
final FavoriteAssetModel asset;
final ApiClient apiClient;
const WatchlistCard({
super.key,
required this.asset,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
final symbol = asset.symbol;
final displayName = asset.name.isNotEmpty ? asset.name : AssetUtils.getAssetName(symbol);
final isPositive = asset.change24h >= 0;
return GlassContainer(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: displayName,
apiClient: apiClient,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
AssetLogoWidget(
symbolOrName: asset.isin.isNotEmpty ? asset.isin : displayName,
imageUrl: asset.image.isNotEmpty ? asset.image : null,
size: 32,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14.5, color: activeTheme.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (symbol != displayName)
Text(symbol, style: TextStyle(color: activeTheme.textMuted, fontSize: 10)),
],
),
),
],
),
),
FavoriteStarButton(
identifier: symbol.isNotEmpty ? symbol : displayName,
symbol: displayName,
name: displayName,
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Live Kurs:', style: TextStyle(color: activeTheme.textMuted, fontSize: 12)),
Text('${asset.currentPrice > 0 ? asset.currentPrice.toStringAsFixed(2) : '--.--'}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: activeTheme.textPrimary)),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Tageswachstum:', style: TextStyle(color: activeTheme.textMuted, fontSize: 12)),
Text(
'${isPositive ? '+' : ''}${asset.change24h.toStringAsFixed(2)}%',
style: TextStyle(
color: isPositive ? AppTheme.primaryEmerald : AppTheme.accentRed,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/news/repositories/news_repository.dart';
import 'news_event.dart';
import 'news_state.dart';
class NewsBloc extends Bloc<NewsEvent, NewsState> {
final NewsRepository repository;
static const int pageSize = 20;
StreamSubscription? _liveNewsSubscription;
NewsBloc({required this.repository}) : super(NewsInitial()) {
on<FetchNews>(_onFetchNews);
on<LoadMoreNews>(_onLoadMoreNews);
on<ReceiveLiveNews>(_onReceiveLiveNews);
// Subscribe to live news from SignalR
_liveNewsSubscription = repository.liveNewsStream.listen((article) {
add(ReceiveLiveNews(article));
});
// Connect to SignalR
repository.connectToLiveFeed();
}
Future<void> _onFetchNews(FetchNews event, Emitter<NewsState> emit) async {
try {
emit(NewsLoading());
final articles = await repository.fetchNews(
page: 1,
pageSize: pageSize,
symbol: event.symbol,
isin: event.isin,
date: event.date,
);
emit(NewsLoaded(
articles: articles,
hasReachedMax: articles.length < pageSize,
currentPage: 1,
symbol: event.symbol,
isin: event.isin,
date: event.date,
));
} catch (e) {
emit(NewsError("Failed to fetch news. Please try again."));
}
}
Future<void> _onLoadMoreNews(LoadMoreNews event, Emitter<NewsState> emit) async {
if (state is NewsLoaded) {
final currentState = state as NewsLoaded;
if (currentState.hasReachedMax) return;
try {
final nextPage = currentState.currentPage + 1;
final articles = await repository.fetchNews(
page: nextPage,
pageSize: pageSize,
symbol: currentState.symbol,
isin: currentState.isin,
date: currentState.date,
);
if (articles.isEmpty) {
emit(currentState.copyWith(hasReachedMax: true));
} else {
emit(NewsLoaded(
articles: currentState.articles + articles,
hasReachedMax: articles.length < pageSize,
currentPage: nextPage,
symbol: currentState.symbol,
isin: currentState.isin,
date: currentState.date,
));
}
} catch (e) {
emit(NewsError("Failed to load more news."));
}
}
}
void _onReceiveLiveNews(ReceiveLiveNews event, Emitter<NewsState> emit) {
if (state is NewsLoaded) {
final currentState = state as NewsLoaded;
// Check if the article matches current filters before prepending
bool matchesFilter = true;
if (currentState.symbol != null || currentState.isin != null) {
// Needs proper matching logic if needed, but for now we prepend if there's no filter or if it matches
// Assuming we prepend it regardless for live feed, or we could filter based on MatchedAssets if added to model.
}
if (matchesFilter) {
// Prepend new article to the top of the list!
final updatedArticles = [event.article, ...currentState.articles];
emit(currentState.copyWith(articles: updatedArticles));
}
}
}
@override
Future<void> close() {
_liveNewsSubscription?.cancel();
repository.dispose();
return super.close();
}
}
@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
abstract class NewsEvent extends Equatable {
const NewsEvent();
@override
List<Object?> get props => [];
}
class FetchNews extends NewsEvent {
final bool isRefresh;
final String? symbol;
final String? isin;
final String? date;
const FetchNews({this.isRefresh = false, this.symbol, this.isin, this.date});
@override
List<Object?> get props => [isRefresh, symbol, isin, date];
}
class LoadMoreNews extends NewsEvent {}
class ReceiveLiveNews extends NewsEvent {
final NewsArticleModel article;
const ReceiveLiveNews(this.article);
@override
List<Object?> get props => [article];
}
@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
abstract class NewsState extends Equatable {
const NewsState();
@override
List<Object?> get props => [];
}
class NewsInitial extends NewsState {}
class NewsLoading extends NewsState {}
class NewsLoaded extends NewsState {
final List<NewsArticleModel> articles;
final bool hasReachedMax;
final int currentPage;
// Filter state preservation
final String? symbol;
final String? isin;
final String? date;
const NewsLoaded({
required this.articles,
this.hasReachedMax = false,
this.currentPage = 1,
this.symbol,
this.isin,
this.date,
});
NewsLoaded copyWith({
List<NewsArticleModel>? articles,
bool? hasReachedMax,
int? currentPage,
String? symbol,
String? isin,
String? date,
}) {
return NewsLoaded(
articles: articles ?? this.articles,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
symbol: symbol ?? this.symbol,
isin: isin ?? this.isin,
date: date ?? this.date,
);
}
@override
List<Object?> get props => [articles, hasReachedMax, currentPage, symbol, isin, date];
}
class NewsError extends NewsState {
final String message;
const NewsError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,59 @@
import 'package:equatable/equatable.dart';
class FinbertResultModel extends Equatable {
final String label;
final double score;
final double positiveProbability;
final double negativeProbability;
final double neutralProbability;
final String processingTimeMs;
final String? summarySnippet;
const FinbertResultModel({
required this.label,
required this.score,
required this.positiveProbability,
required this.negativeProbability,
required this.neutralProbability,
required this.processingTimeMs,
this.summarySnippet,
});
factory FinbertResultModel.fromJson(Map<String, dynamic> json) {
Map<String, dynamic>? probs = json['probabilities'] ?? json['Probabilities'];
return FinbertResultModel(
label: json['label'] ?? json['Label'] ?? 'NEUTRAL',
score: (json['compound_score'] ?? json['compoundScore'] ?? json['score'] ?? json['Score'] ?? 0.0).toDouble(),
positiveProbability: (probs != null ? (probs['positive'] ?? probs['Positive'] ?? 0.0) : (json['positiveProbability'] ?? json['PositiveProbability'] ?? 0.0)).toDouble(),
negativeProbability: (probs != null ? (probs['negative'] ?? probs['Negative'] ?? 0.0) : (json['negativeProbability'] ?? json['NegativeProbability'] ?? 0.0)).toDouble(),
neutralProbability: (probs != null ? (probs['neutral'] ?? probs['Neutral'] ?? 0.0) : (json['neutralProbability'] ?? json['NeutralProbability'] ?? 0.0)).toDouble(),
processingTimeMs: json['processingTimeMs']?.toString() ?? json['ProcessingTimeMs']?.toString() ?? '0ms',
summarySnippet: json['summary_snippet']?.toString() ?? json['summarySnippet']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'label': label,
'score': score,
'positiveProbability': positiveProbability,
'negativeProbability': negativeProbability,
'neutralProbability': neutralProbability,
'processingTimeMs': processingTimeMs,
'summarySnippet': summarySnippet,
};
}
@override
List<Object?> get props => [
label,
score,
positiveProbability,
negativeProbability,
neutralProbability,
processingTimeMs,
summarySnippet,
];
}
@@ -0,0 +1,99 @@
import 'package:equatable/equatable.dart';
import 'finbert_result_model.dart';
class NewsArticleModel extends Equatable {
final String id;
final String title;
final String author;
final String summary;
final String contentRaw;
final String sourceUrl;
final DateTime scrapedAt;
final DateTime publishedAt;
final String status;
// Flatted sentiment properties
final String sentiment;
final double sentimentScore;
final double confidence;
final FinbertResultModel? finbertResult;
const NewsArticleModel({
required this.id,
required this.title,
required this.author,
required this.summary,
required this.contentRaw,
required this.sourceUrl,
required this.scrapedAt,
required this.publishedAt,
required this.status,
required this.sentiment,
required this.sentimentScore,
required this.confidence,
this.finbertResult,
});
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
return NewsArticleModel(
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title',
author: json['author']?.toString() ?? json['Author']?.toString() ?? 'Unknown',
summary: json['summary']?.toString() ?? json['Summary']?.toString() ?? '',
contentRaw: json['contentRaw']?.toString() ?? json['ContentRaw']?.toString() ?? '',
sourceUrl: json['sourceUrl']?.toString() ?? json['SourceUrl']?.toString() ?? '',
scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? json['ScrapedAt']?.toString() ?? '') ?? DateTime.now(),
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? json['PublishedAt']?.toString() ?? '') ?? DateTime.now(),
status: json['status']?.toString() ?? json['Status']?.toString() ?? 'Completed',
sentiment: json['sentiment']?.toString() ?? json['Sentiment']?.toString() ?? '',
sentimentScore: (json['sentimentScore'] ?? json['SentimentScore'] ?? 0.0).toDouble(),
confidence: (json['confidence'] ?? json['Confidence'] ?? 0.0).toDouble(),
finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null)
? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'author': author,
'summary': summary,
'contentRaw': contentRaw,
'sourceUrl': sourceUrl,
'scrapedAt': scrapedAt.toIso8601String(),
'publishedAt': publishedAt.toIso8601String(),
'status': status,
'sentiment': sentiment,
'sentimentScore': sentimentScore,
'confidence': confidence,
'finbertResult': finbertResult != null ? {
'label': finbertResult!.label,
'score': finbertResult!.score,
'positiveProbability': finbertResult!.positiveProbability,
'negativeProbability': finbertResult!.negativeProbability,
'neutralProbability': finbertResult!.neutralProbability,
'processingTimeMs': finbertResult!.processingTimeMs,
} : null,
};
}
@override
List<Object?> get props => [
id,
title,
author,
summary,
contentRaw,
sourceUrl,
scrapedAt,
publishedAt,
status,
sentiment,
sentimentScore,
confidence,
finbertResult,
];
}
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:convert';
import 'package:signalr_core/signalr_core.dart';
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class NewsRepository {
final ApiClient apiClient;
final String backendUrl;
final FlutterSecureStorage secureStorage = const FlutterSecureStorage();
HubConnection? _hubConnection;
final _liveNewsController = StreamController<NewsArticleModel>.broadcast();
Stream<NewsArticleModel> get liveNewsStream => _liveNewsController.stream;
NewsRepository({required this.apiClient, required this.backendUrl});
Future<List<NewsArticleModel>> fetchNews({
int page = 1,
int pageSize = 20,
String? symbol,
String? isin,
String? date,
}) async {
try {
final Map<String, dynamic> queryParams = {
'page': page,
'pageSize': pageSize,
};
if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol;
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
if (date != null && date.isNotEmpty) queryParams['date'] = date;
final response = await apiClient.get('/api/v1/news', queryParameters: queryParams);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => NewsArticleModel.fromJson(json)).toList();
}
return [];
} catch (e) {
print('Error fetching news: $e');
throw Exception('Failed to load news');
}
}
Future<void> connectToLiveFeed() async {
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
return;
}
try {
final token = await secureStorage.read(key: 'auth_token');
final hubUrl = '$backendUrl/hubs/news${token != null ? '?access_token=$token' : ''}';
_hubConnection = HubConnectionBuilder()
.withUrl(hubUrl, HttpConnectionOptions(
logging: (level, message) => print(message),
))
.withAutomaticReconnect()
.build();
_hubConnection!.on('ReceiveNewArticle', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
// SignalR might send it as a Map or raw JSON depending on .NET format
final dynamic rawPayload = arguments[0];
final Map<String, dynamic> jsonData = rawPayload is String
? jsonDecode(rawPayload)
: Map<String, dynamic>.from(rawPayload);
final article = NewsArticleModel.fromJson(jsonData);
_liveNewsController.add(article);
} catch (e) {
print('Error parsing live article: $e');
}
}
});
await _hubConnection!.start();
print('Connected to News Live Feed');
} catch (e) {
print('Error connecting to News Live Feed: $e');
}
}
Future<void> disconnectFromLiveFeed() async {
if (_hubConnection != null) {
await _hubConnection!.stop();
_hubConnection = null;
}
}
void dispose() {
_liveNewsController.close();
disconnectFromLiveFeed();
}
}
@@ -0,0 +1,210 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../widgets/news_card_item.dart';
import '../widgets/advanced_news_filter_bar.dart';
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting.
class NewsFeedScreen extends StatefulWidget {
final ApiClient apiClient;
const NewsFeedScreen({super.key, required this.apiClient});
@override
State<NewsFeedScreen> createState() => _NewsFeedScreenState();
}
class _NewsFeedScreenState extends State<NewsFeedScreen> {
final ScrollController _scrollController = ScrollController();
final List<dynamic> _newsItems = [];
int _currentPage = 1;
static const int _pageSize = 15;
bool _isLoading = false;
bool _hasMore = true;
// Filter States
String? _searchQuery;
DateTime? _selectedDate;
String? _selectedIsin;
bool _hasSentimentOnly = false;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_loadNews(refresh: true);
_scrollController.addListener(() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
_loadNews();
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.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 {
if (_isLoading) return;
if (refresh) {
_currentPage = 1;
_hasMore = true;
_newsItems.clear();
}
if (!_hasMore) return;
setState(() => _isLoading = true);
try {
final queryParams = <String, dynamic>{
'page': _currentPage,
'pageSize': _pageSize,
};
if (_selectedDate != null) {
queryParams['date'] = _selectedDate!.toIso8601String().substring(0, 10);
}
if (_searchQuery != null && _searchQuery!.trim().isNotEmpty) {
queryParams['query'] = _searchQuery!.trim();
}
if (_selectedIsin != null && _selectedIsin!.trim().isNotEmpty) {
queryParams['isin'] = _selectedIsin!.trim();
}
if (_hasSentimentOnly) {
queryParams['hasSentiment'] = true;
}
final res = await widget.apiClient.get('/api/v1/news', queryParameters: queryParams);
if (res.statusCode == 200 && res.data != null && res.data is List) {
final List fetched = res.data as List;
setState(() {
// Deduplicate by ID
final existingIds = _newsItems.map((e) => e['id'] ?? e['Id']).where((id) => id != null).toSet();
for (final item in fetched) {
final id = item['id'] ?? item['Id'];
if (id == null || !existingIds.contains(id)) {
_newsItems.add(item);
if (id != null) existingIds.add(id);
}
}
// Re-sort strictly by publication timestamp descending (newest articles at the top)
_newsItems.sort((a, b) {
final dtA = _parseDateTime(a['publishedAt'] ?? a['PublishedAt'] ?? a['scrapedAt'] ?? a['ScrapedAt']);
final dtB = _parseDateTime(b['publishedAt'] ?? b['PublishedAt'] ?? b['scrapedAt'] ?? b['ScrapedAt']);
return dtB.compareTo(dtA);
});
_currentPage++;
if (fetched.length < _pageSize) {
_hasMore = false;
}
});
}
} catch (_) {
// Handle error visually if necessary, currently silent fallback
} finally {
setState(() => _isLoading = false);
}
}
void _onSearchChanged(String? val) {
_searchQuery = val;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
_loadNews(refresh: true);
});
}
void _onIsinChanged(String? val) {
_selectedIsin = val;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
_loadNews(refresh: true);
});
}
void _resetFilters() {
_debounceTimer?.cancel();
setState(() {
_searchQuery = null;
_selectedDate = null;
_selectedIsin = null;
_hasSentimentOnly = false;
});
_loadNews(refresh: true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
AdvancedNewsFilterBar(
searchQuery: _searchQuery,
selectedDate: _selectedDate,
selectedIsin: _selectedIsin,
hasSentimentOnly: _hasSentimentOnly,
onSearchChanged: _onSearchChanged,
onIsinChanged: _onIsinChanged,
onDateChanged: (val) {
setState(() => _selectedDate = val);
_loadNews(refresh: true);
},
onSentimentToggleChanged: (val) {
setState(() => _hasSentimentOnly = val);
_loadNews(refresh: true);
},
onResetFilters: _resetFilters,
),
const SizedBox(height: 16),
Expanded(
child: _newsItems.isEmpty && !_isLoading
? Center(
child: Text('Keine Nachrichten für diese Filterkriterien gefunden.', style: TextStyle(color: AppTheme.textMuted)),
)
: ListView.builder(
controller: _scrollController,
itemCount: _newsItems.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _newsItems.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: CircularProgressIndicator(color: AppTheme.primaryEmerald),
),
);
}
return NewsCardItem(item: _newsItems[index], apiClient: widget.apiClient);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
class AdvancedNewsFilterBar extends StatelessWidget {
final String? searchQuery;
final DateTime? selectedDate;
final String? selectedIsin;
final bool hasSentimentOnly;
final Function(String?) onSearchChanged;
final Function(DateTime?) onDateChanged;
final Function(String?) onIsinChanged;
final Function(bool) onSentimentToggleChanged;
final VoidCallback onResetFilters;
const AdvancedNewsFilterBar({
super.key,
required this.searchQuery,
required this.selectedDate,
required this.selectedIsin,
required this.hasSentimentOnly,
required this.onSearchChanged,
required this.onDateChanged,
required this.onIsinChanged,
required this.onSentimentToggleChanged,
required this.onResetFilters,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: TextEditingController(text: searchQuery)..selection = TextSelection.collapsed(offset: searchQuery?.length ?? 0),
onChanged: onSearchChanged,
style: const TextStyle(fontSize: 14, color: Colors.white),
decoration: InputDecoration(
hintText: 'Nachrichten durchsuchen...',
hintStyle: TextStyle(color: AppTheme.textMuted),
prefixIcon: Icon(Icons.search, color: AppTheme.textMuted, size: 20),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
filled: true,
fillColor: Colors.black.withValues(alpha: 0.2),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.primaryEmerald),
),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: TextField(
controller: TextEditingController(text: selectedIsin)..selection = TextSelection.collapsed(offset: selectedIsin?.length ?? 0),
onChanged: onIsinChanged,
style: const TextStyle(fontSize: 14, color: Colors.white),
decoration: InputDecoration(
hintText: 'Wertpapier ISIN...',
hintStyle: TextStyle(color: AppTheme.textMuted),
prefixIcon: Icon(Icons.business, color: AppTheme.textMuted, size: 20),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
filled: true,
fillColor: Colors.black.withValues(alpha: 0.2),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.primaryEmerald),
),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.dark(
primary: AppTheme.primaryEmerald,
onPrimary: Colors.black,
surface: AppTheme.darkBackground,
onSurface: Colors.white,
),
),
child: child!,
);
},
);
if (date != null) {
onDateChanged(date);
}
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selectedDate != null ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder),
),
child: Row(
children: [
Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted),
const SizedBox(width: 8),
Text(
selectedDate != null ? DateFormat('dd.MM.yyyy').format(selectedDate!) : 'Datum wählen',
style: TextStyle(
fontSize: 13,
color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted,
fontWeight: selectedDate != null ? FontWeight.bold : FontWeight.normal,
),
),
if (selectedDate != null) ...[
const SizedBox(width: 4),
GestureDetector(
onTap: () => onDateChanged(null),
child: Icon(Icons.close, size: 16, color: AppTheme.primaryEmerald),
),
],
],
),
),
),
const SizedBox(width: 16),
FilterChip(
label: const Text('Nur Analysiert (KI)'),
selected: hasSentimentOnly,
onSelected: onSentimentToggleChanged,
backgroundColor: Colors.black.withValues(alpha: 0.2),
selectedColor: AppTheme.accentCyan.withValues(alpha: 0.2),
checkmarkColor: AppTheme.accentCyan,
side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder),
labelStyle: TextStyle(
color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.textMuted,
fontSize: 13,
),
),
const Spacer(),
TextButton.icon(
onPressed: onResetFilters,
icon: Icon(Icons.refresh, size: 16, color: AppTheme.textMuted),
label: Text('Reset Filter', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
),
],
),
],
),
);
}
}
@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/news_article_model.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/status_badge.dart';
/// Two-Tab Dialog for Article details and real FinBERT Sentiment Analysis.
class ArticleSentimentDialog extends StatefulWidget {
final NewsArticleModel articleData;
const ArticleSentimentDialog({
super.key,
required this.articleData,
});
@override
State<ArticleSentimentDialog> createState() => _ArticleSentimentDialogState();
}
class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.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 {
final urlStr = widget.articleData.sourceUrl;
if (urlStr.isNotEmpty) {
final uri = Uri.parse(urlStr);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
Map<String, dynamic>? get _realSentimentData {
if (widget.articleData.finbertResult != null) {
return {'finbert_result': widget.articleData.finbertResult!.toJson()};
}
return null;
}
bool get _isLoadingSentiment => false;
@override
Widget build(BuildContext context) {
final title = _findString(['Title', 'title']) ?? 'Nachrichtenartikel';
final author = _findString(['Author', 'author', 'Source', 'source']) ?? 'Finlytic News';
final summary = _findString(['Summary', 'summary']);
final sourceUrl = _findString(['SourceUrl', 'sourceUrl']);
final contentRaw = _findString(['ContentRaw', 'contentRaw', 'content', 'Text', 'text']);
final publishedAt = _findString(['PublishedAt', 'publishedAt', 'ScrapedAt', 'scrapedAt']) ?? '';
final status = _findString(['Status', 'status']) ?? 'Completed';
final rawSentiment = _findString(['sentiment', 'Sentiment', 'sentimentLabel', 'SentimentLabel']);
final articleMap = widget.articleData.toJson();
final Map<String, dynamic> articleObj = articleMap.containsKey('article') && articleMap['article'] is Map
? Map<String, dynamic>.from(articleMap['article'])
: articleMap;
final matchedAssetsRaw = articleObj['MatchedAssets'] ?? articleObj['matchedAssets'] ?? articleMap['MatchedAssets'] ?? articleMap['matchedAssets'];
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
final double compoundScore = widget.articleData.finbertResult?.score ?? widget.articleData.sentimentScore;
final double confidenceScore = widget.articleData.confidence;
final String label = (widget.articleData.finbertResult?.label ?? widget.articleData.sentiment).toString().toUpperCase();
final double posRatio = widget.articleData.finbertResult?.positiveProbability ?? 0.0;
final double neuRatio = widget.articleData.finbertResult?.neutralProbability ?? 0.0;
final double negRatio = widget.articleData.finbertResult?.negativeProbability ?? 0.0;
final String aiText = widget.articleData.finbertResult?.summarySnippet ?? summary ?? 'FinBERT Sentiment-Analyse verarbeitet.';
final Widget listBadge = rawSentiment != null
? StatusBadge.sentiment(rawSentiment.toUpperCase(), score: compoundScore)
: StatusBadge(
label: status.toUpperCase(),
color: status.toLowerCase().contains('analyz') || status.toLowerCase().contains('klassifi')
? AppTheme.primaryEmerald
: AppTheme.accentCyan,
);
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
child: Container(
width: 700,
height: 620,
padding: const EdgeInsets.all(20),
child: Column(
children: [
TabBar(
controller: _tabController,
indicatorColor: AppTheme.primaryEmerald,
labelColor: AppTheme.primaryEmerald,
unselectedLabelColor: AppTheme.textMuted,
tabs: const [
Tab(icon: Icon(Icons.article_outlined), text: 'Artikel & Volltext'),
Tab(icon: Icon(Icons.psychology_outlined), text: 'Sentiment-Analyse (FinBERT)'),
],
),
const SizedBox(height: 14),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: Artikel & Volltext
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.3)),
),
if (sourceUrl != null && sourceUrl.isNotEmpty)
IconButton(
icon: Icon(Icons.open_in_new, color: AppTheme.primaryEmerald, size: 22),
tooltip: 'Originalquelle lesen',
onPressed: _openOriginalSource,
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'$author ${publishedAt.isNotEmpty ? "$publishedAt" : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
),
listBadge,
],
),
if (matchedAssets.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Verknüpfte Wertpapiere (Matched Assets):', style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: matchedAssets.map((a) {
final name = a['Name'] ?? a['name'] ?? a['Isin'] ?? a['isin'] ?? 'Asset';
final isin = a['Isin'] ?? a['isin'] ?? '';
return Chip(
label: Text(
isin.toString().isNotEmpty && name.toString() != isin.toString()
? '${name.toString()} ($isin)'
: name.toString(),
style: TextStyle(fontSize: 11, color: AppTheme.accentCyan),
),
backgroundColor: AppTheme.glassSurface,
side: BorderSide(color: AppTheme.glassBorder),
padding: EdgeInsets.zero,
);
}).toList(),
),
],
Divider(height: 24, color: AppTheme.glassBorder),
if (summary != null && summary.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.accentCyan, fontSize: 12)),
const SizedBox(height: 4),
Text(summary, style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textPrimary)),
],
),
),
const SizedBox(height: 16),
],
const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
Text(
contentRaw ?? summary ?? 'Kein Volltext verfügbar.',
style: TextStyle(fontSize: 13.5, height: 1.6, color: AppTheme.textSecondary),
),
],
),
),
// Tab 2: Real Sentiment Analysis (FinBERT)
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('FinBERT KI Klassifizierung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (_realSentimentData != null) StatusBadge.sentiment(label, score: compoundScore),
],
),
const SizedBox(height: 16),
if (_isLoadingSentiment)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
),
)
else if (_realSentimentData == null)
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 44),
const SizedBox(height: 12),
const Text(
'Keine Sentiment-Analyse vorhanden',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Für diesen Artikel liegt aktuell noch keine FinBERT-Sentiment-Analyse vor.',
style: TextStyle(fontSize: 13, color: AppTheme.textMuted, height: 1.4),
textAlign: TextAlign.center,
),
],
),
)
else ...[
Row(
children: [
SentimentMetricCard(
title: 'Compound Score',
valueText: '${compoundScore > 0 ? "+" : ""}${compoundScore.toStringAsFixed(2)}',
tooltipText: 'Der Compound-Score misst die aggregierte Gesamtausrichtung der Nachricht auf einer Skala von -1.00 (sehr negativ) bis +1.00 (sehr positiv). Werte ab +0.15 gelten als positiv.',
accentColor: compoundScore > 0.15
? AppTheme.primaryEmerald
: (compoundScore < -0.15 ? AppTheme.accentRed : AppTheme.accentCyan),
),
const SizedBox(width: 12),
SentimentMetricCard(
title: 'KI-Confidence',
valueText: '${(confidenceScore * 100).toInt()}%',
tooltipText: 'Die Confidence gibt die statistische Wahrscheinlichkeit (0% bis 100%) an, mit welcher die FinBERT KI ihre Stimmungszuordnung berechnet hat.',
accentColor: AppTheme.accentCyan,
progressValue: confidenceScore,
),
],
),
const SizedBox(height: 16),
Text('Echte FinBERT Softmax-Verteilung:', style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
ArticleSentimentMeterBar(label: 'Positiv', ratio: posRatio, color: AppTheme.primaryEmerald),
const SizedBox(height: 6),
ArticleSentimentMeterBar(label: 'Neutral', ratio: neuRatio, color: AppTheme.accentCyan),
const SizedBox(height: 6),
ArticleSentimentMeterBar(label: 'Negativ', ratio: negRatio, color: AppTheme.accentRed),
Divider(height: 24, color: AppTheme.glassBorder),
const Text('KI-Zusammenfassung & Auswirkung:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text(
aiText,
style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textSecondary),
),
],
],
),
),
],
),
),
],
),
),
);
}
}
/// Extracted metric card component with interactive info explanation tooltips.
class SentimentMetricCard extends StatelessWidget {
final String title;
final String valueText;
final String tooltipText;
final Color accentColor;
final double? progressValue;
const SentimentMetricCard({
super.key,
required this.title,
required this.valueText,
required this.tooltipText,
required this.accentColor,
this.progressValue,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: TextStyle(fontSize: 12, color: AppTheme.textMuted, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
Tooltip(
message: tooltipText,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: const Color(0xFF1E2130),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
textStyle: const TextStyle(color: Colors.white, fontSize: 12, height: 1.3),
child: Icon(Icons.info_outline, size: 16, color: AppTheme.accentCyan),
),
],
),
const SizedBox(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,129 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/time_utils.dart';
import '../../../core/widgets/glass_container.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../models/news_article_model.dart';
import 'article_sentiment_dialog.dart';
/// News Card Item displaying news info, tagged assets, timestamp, and sentiment.
class NewsCardItem extends StatelessWidget {
final dynamic item;
final ApiClient apiClient;
const NewsCardItem({
super.key,
required this.item,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final Map<String, dynamic> article = item is NewsArticleModel
? (item as NewsArticleModel).toJson()
: (item is Map && item.containsKey('article') && item['article'] is Map
? Map<String, dynamic>.from(item['article'])
: (item is Map ? Map<String, dynamic>.from(item) : {}));
final Map<String, dynamic>? sentimentObj = (item is Map && item.containsKey('sentiment') && item['sentiment'] is Map)
? Map<String, dynamic>.from(item['sentiment'])
: null;
final title = article['title']?.toString() ?? article['Title']?.toString() ?? 'Nachrichtenartikel';
final summary = article['summary']?.toString() ?? article['Summary']?.toString() ?? article['contentRaw']?.toString() ?? '';
final author = article['author']?.toString() ?? article['Author']?.toString() ?? article['source']?.toString() ?? 'Finlytic News';
final rawPubTime = article['publishedAt']?.toString() ?? article['PublishedAt']?.toString() ?? article['scrapedAt']?.toString();
final formattedTime = TimeUtils.formatRelativeTime(rawPubTime);
final pubTime = formattedTime.isNotEmpty ? formattedTime : 'Heute';
final rawStatus = article['status']?.toString() ?? article['Status']?.toString() ?? 'Completed';
final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString();
final double? score = ((sentimentObj?['compoundScore'] ?? sentimentObj?['compound_score'] ?? article['sentimentScore']) as num?)?.toDouble();
final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets'];
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
return GlassContainer(
margin: const EdgeInsets.only(bottom: 12),
onTap: () {
final NewsArticleModel articleModel;
if (item is NewsArticleModel) {
articleModel = item;
} else if (item is Map<String, dynamic>) {
articleModel = NewsArticleModel.fromJson(item);
} else if (item is Map) {
articleModel = NewsArticleModel.fromJson(Map<String, dynamic>.from(item));
} else {
articleModel = NewsArticleModel(
id: '',
title: title,
author: author,
summary: summary,
contentRaw: '',
sourceUrl: '',
scrapedAt: DateTime.now(),
publishedAt: DateTime.now(),
status: rawStatus,
sentiment: sentimentLabel ?? 'NEUTRAL',
sentimentScore: score ?? 0.0,
confidence: 0.0,
);
}
showDialog(
context: context,
builder: (_) => ArticleSentimentDialog(articleData: articleModel),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
),
const SizedBox(width: 8),
//badgeWidget,
],
),
const SizedBox(height: 8),
Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 10),
Text('$author$pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
if (matchedAssets.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: matchedAssets.map((assetItem) {
final assetSymbol = assetItem['Name']?.toString() ?? assetItem['name']?.toString() ?? assetItem['Isin']?.toString() ?? assetItem['isin']?.toString() ?? 'ASSET';
return ActionChip(
label: Text(assetSymbol, style: TextStyle(fontSize: 10, color: AppTheme.accentCyan)),
backgroundColor: AppTheme.glassSurface,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: assetSymbol,
apiClient: apiClient,
),
),
);
},
);
}).toList(),
),
],
],
),
);
}
}
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/search/repositories/search_repository.dart';
import 'search_event.dart';
import 'search_state.dart';
export 'search_event.dart';
export 'search_state.dart';
EventTransformer<Event> debounce<Event>(Duration duration) {
return (events, mapper) => events.switchMap((event) => Stream.value(event).asyncExpand(mapper));
}
extension SwitchMapExtension<T> on Stream<T> {
Stream<R> switchMap<R>(Stream<R> Function(T event) mapper) {
StreamController<R>? controller;
StreamSubscription<T>? inputSub;
StreamSubscription<R>? outputSub;
controller = StreamController<R>(
onListen: () {
inputSub = listen((event) {
outputSub?.cancel();
outputSub = mapper(event).listen(
controller?.add,
onError: controller?.addError,
);
}, onDone: () {
// Wait for last sub to finish or close
});
},
onCancel: () {
outputSub?.cancel();
inputSub?.cancel();
},
);
return controller.stream;
}
}
class SearchBloc extends Bloc<SearchEvent, SearchState> {
final SearchRepository repository;
SearchBloc({required this.repository}) : super(SearchInitial()) {
on<SearchQueryChanged>(_onSearchQueryChanged);
}
Future<void> _onSearchQueryChanged(SearchQueryChanged event, Emitter<SearchState> emit) async {
emit(SearchLoading());
try {
final results = await repository.searchAssets(event.query);
emit(SearchLoaded(results));
} catch (e) {
emit(const SearchError("Fehler bei der Suche."));
}
}
}
@@ -0,0 +1,17 @@
import 'package:equatable/equatable.dart';
abstract class SearchEvent extends Equatable {
const SearchEvent();
@override
List<Object?> get props => [];
}
class SearchQueryChanged extends SearchEvent {
final String query;
const SearchQueryChanged(this.query);
@override
List<Object?> get props => [query];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/search/models/search_result_model.dart';
abstract class SearchState extends Equatable {
const SearchState();
@override
List<Object?> get props => [];
}
class SearchInitial extends SearchState {}
class SearchLoading extends SearchState {}
class SearchLoaded extends SearchState {
final List<SearchResultModel> results;
const SearchLoaded(this.results);
@override
List<Object?> get props => [results];
}
class SearchError extends SearchState {
final String message;
const SearchError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
class SearchResultModel extends Equatable {
final String isin;
final String name;
final String symbol;
final String? image;
const SearchResultModel({
required this.isin,
required this.name,
required this.symbol,
this.image,
});
factory SearchResultModel.fromJson(Map<String, dynamic> json) {
return SearchResultModel(
isin: json['isin']?.toString() ?? json['Isin']?.toString() ?? '',
name: json['name']?.toString() ?? json['Name']?.toString() ?? '',
symbol: json['symbol']?.toString() ?? json['Symbol']?.toString() ?? '',
image: json['image']?.toString() ?? json['Image']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'isin': isin,
'name': name,
'symbol': symbol,
'image': image,
};
}
String get displayName => name.isNotEmpty ? name : (symbol.isNotEmpty ? symbol : isin);
String get isinCode => isin.isNotEmpty ? isin : (symbol != displayName ? symbol : '');
@override
List<Object?> get props => [isin, name, symbol, image];
}
@@ -0,0 +1,28 @@
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/search/models/search_result_model.dart';
import 'package:finlytic_app/core/utils/asset_utils.dart';
class SearchRepository {
final ApiClient apiClient;
SearchRepository({required this.apiClient});
Future<List<SearchResultModel>> searchAssets(String query) async {
try {
final res = await apiClient.get('/api/v1/assets/search', queryParameters: {'q': query});
if (res.statusCode == 200 && res.data != null) {
final list = res.data as List;
final results = list.map((json) => SearchResultModel.fromJson(json)).toList();
for (final item in results) {
if (item.isinCode.isNotEmpty && item.displayName.isNotEmpty) {
AssetUtils.registerAsset(item.isinCode, item.displayName, item.image);
}
}
return results;
}
return [];
} catch (_) {
return [];
}
}
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/asset_utils.dart';
import '../../../core/widgets/asset_logo_widget.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../../shared/widgets/favorite_star_button.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../bloc/search_bloc.dart';
import '../repositories/search_repository.dart';
/// Focused Spotlight Asset Search Dialog with reactive Favorite Star Button, Hero transitions, and Shimmer loading states.
class AssetSearchDialog extends StatelessWidget {
final ApiClient apiClient;
const AssetSearchDialog({
super.key,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SearchBloc(
repository: SearchRepository(apiClient: apiClient),
)..add(const SearchQueryChanged('')),
child: _AssetSearchDialogContent(apiClient: apiClient),
);
}
}
class _AssetSearchDialogContent extends StatefulWidget {
final ApiClient apiClient;
const _AssetSearchDialogContent({required this.apiClient});
@override
State<_AssetSearchDialogContent> createState() => _AssetSearchDialogContentState();
}
class _AssetSearchDialogContentState extends State<_AssetSearchDialogContent> {
final _searchController = TextEditingController();
@override
Widget build(BuildContext context) {
final activeTheme = AppTheme.activePreset;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
backgroundColor: activeTheme.cardSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(activeTheme.borderRadius + 2),
side: BorderSide(color: activeTheme.glassBorder, width: 1),
),
child: Container(
width: 600,
height: 520,
padding: const EdgeInsets.all(20),
child: Column(
children: [
BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) {
final isLoading = state is SearchLoading;
return TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Aktienname oder ISIN suchen...',
prefixIcon: Icon(Icons.search, color: activeTheme.primaryColor),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: activeTheme.primaryColor),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
onChanged: (query) {
context.read<SearchBloc>().add(SearchQueryChanged(query));
},
);
},
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) {
if (state is SearchLoading) {
return ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
ShimmerLoading(width: 36, height: 36, borderRadius: 10),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShimmerLoading(width: 140, height: 14),
const SizedBox(height: 6),
ShimmerLoading(width: 80, height: 10),
],
),
),
],
),
),
);
}
if (state is SearchError) {
return Center(
child: Text(state.message, style: TextStyle(color: activeTheme.textMuted, fontSize: 13)),
);
}
if (state is SearchLoaded) {
final results = state.results;
if (results.isEmpty) {
return Center(
child: Text('Keine Wertpapiere gefunden', style: TextStyle(color: activeTheme.textMuted, fontSize: 13)),
);
}
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final item = results[index];
final assetName = item.displayName;
final isinCode = item.isinCode;
if (isinCode.isNotEmpty && assetName.isNotEmpty) {
AssetUtils.registerAsset(isinCode, assetName, item.image);
}
final targetId = isinCode.isNotEmpty ? isinCode : assetName;
return ListTile(
leading: AssetLogoWidget(
symbolOrName: targetId,
imageUrl: item.image,
size: 36,
),
title: Text(assetName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
subtitle: isinCode.isNotEmpty
? Text('ISIN: $isinCode', style: TextStyle(color: activeTheme.textMuted, fontSize: 11))
: null,
trailing: SizedBox(
width: 40,
height: 40,
child: FavoriteStarButton(
identifier: targetId,
symbol: assetName,
name: assetName,
),
),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: assetName,
apiClient: widget.apiClient,
),
),
);
},
);
},
);
}
return const SizedBox.shrink();
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/trades/repositories/trade_repository.dart';
import 'trade_event.dart';
import 'trade_state.dart';
class TradeBloc extends Bloc<TradeEvent, TradeState> {
final TradeRepository repository;
TradeBloc({required this.repository}) : super(TradeInitial()) {
on<FetchTrades>(_onFetchTrades);
on<CloseTrade>(_onCloseTrade);
on<AcceptTradeProposalEvent>(_onAcceptTradeProposal);
}
Future<void> _onFetchTrades(FetchTrades event, Emitter<TradeState> emit) async {
emit(TradeLoading());
try {
final trades = await repository.fetchTrades(isin: event.isin, status: event.status);
emit(TradeLoaded(trades));
} catch (e) {
emit(const TradeError("Trades konnten nicht geladen werden"));
}
}
Future<void> _onCloseTrade(CloseTrade event, Emitter<TradeState> emit) async {
emit(TradeLoading());
try {
await repository.closeTrade(event.tradeId);
final trades = await repository.fetchTrades();
emit(TradeLoaded(trades));
} catch (e) {
emit(const TradeError("Position konnte nicht geschlossen werden"));
}
}
Future<void> _onAcceptTradeProposal(AcceptTradeProposalEvent event, Emitter<TradeState> emit) async {
emit(TradeLoading());
try {
await repository.acceptTrade(event.dto);
final trades = await repository.fetchTrades();
emit(TradeLoaded(trades));
} catch (e) {
emit(const TradeError("Trade konnte nicht akzeptiert werden"));
}
}
}
@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
import '../models/trade_acceptance_dto.dart';
abstract class TradeEvent extends Equatable {
const TradeEvent();
@override
List<Object?> get props => [];
}
class FetchTrades extends TradeEvent {
final String? status;
final String? isin;
const FetchTrades({this.status, this.isin});
@override
List<Object?> get props => [status, isin];
}
class CloseTrade extends TradeEvent {
final String tradeId;
const CloseTrade(this.tradeId);
@override
List<Object?> get props => [tradeId];
}
class AcceptTradeProposalEvent extends TradeEvent {
final TradeAcceptanceDto dto;
const AcceptTradeProposalEvent(this.dto);
@override
List<Object?> get props => [dto];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/trades/models/trade_model.dart';
abstract class TradeState extends Equatable {
const TradeState();
@override
List<Object?> get props => [];
}
class TradeInitial extends TradeState {}
class TradeLoading extends TradeState {}
class TradeLoaded extends TradeState {
final List<TradeModel> trades;
const TradeLoaded(this.trades);
@override
List<Object?> get props => [trades];
}
class TradeError extends TradeState {
final String message;
const TradeError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,74 @@
class TradeAcceptanceDto {
final String userId;
final String tradeId;
final String analysisId;
final String isin;
final String symbol;
final double? actualEntryPrice;
final double? positionSize;
final double? leverageUsed;
final double? entryFee;
final double? exitFee;
final double? quantity;
final double? knockoutThreshold;
final bool isRecurring;
final DateTime executionTimestamp;
final String? signalType;
final double? entryPrice;
final double? stopLoss;
final double? takeProfit;
final String? instrumentType;
final String? timeframe;
final String? reasoning;
TradeAcceptanceDto({
required this.userId,
required this.tradeId,
this.analysisId = '',
required this.isin,
required this.symbol,
this.actualEntryPrice,
this.positionSize,
this.leverageUsed,
this.entryFee,
this.exitFee,
this.quantity,
this.knockoutThreshold,
this.isRecurring = false,
required this.executionTimestamp,
this.signalType,
this.entryPrice,
this.stopLoss,
this.takeProfit,
this.instrumentType,
this.timeframe,
this.reasoning,
});
Map<String, dynamic> toJson() {
return {
'userId': userId.isNotEmpty ? userId : 'default_user',
'tradeId': tradeId,
'analysisId': analysisId,
'isin': isin,
'symbol': symbol,
'actualEntryPrice': actualEntryPrice,
'positionSize': positionSize,
'leverageUsed': leverageUsed,
'entryFee': entryFee,
'exitFee': exitFee,
'quantity': quantity,
'knockoutThreshold': knockoutThreshold,
'isRecurring': isRecurring,
'executionTimestamp': executionTimestamp.toIso8601String(),
'signalType': signalType,
'entryPrice': entryPrice,
'stopLoss': stopLoss,
'takeProfit': takeProfit,
'instrumentType': instrumentType,
'timeframe': timeframe,
'reasoning': reasoning,
};
}
}

Some files were not shown because too many files have changed in this diff Show More