feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../services/secure_storage_service.dart';
|
||||
|
||||
/// Central HTTP ApiClient backed by Dio with automatic 401 Unauthorized handling.
|
||||
class ApiClient {
|
||||
final SecureStorageService _storageService;
|
||||
late final Dio _dio;
|
||||
Function()? onUnauthorized;
|
||||
|
||||
static const String baseUrl = 'http://localhost:5000';
|
||||
|
||||
ApiClient(this._storageService) {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
),
|
||||
);
|
||||
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storageService.getToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
onError: (DioException error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
await _storageService.clearAll();
|
||||
onUnauthorized?.call();
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
return await _dio.get(path, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
Future<Response> post(String path, {dynamic data}) async {
|
||||
return await _dio.post(path, data: data);
|
||||
}
|
||||
|
||||
Future<Response> put(String path, {dynamic data}) async {
|
||||
return await _dio.put(path, data: data);
|
||||
}
|
||||
|
||||
Future<Response> delete(String path) async {
|
||||
return await _dio.delete(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signalr_core/signalr_core.dart';
|
||||
import '../services/secure_storage_service.dart';
|
||||
|
||||
/// Central Real-Time WebSocket Service utilizing SignalR (`signalr_core`).
|
||||
/// Connects persistently to `/hubs/health` and `/hubs/favorites-prices` WebSockets.
|
||||
class SignalRService extends ChangeNotifier {
|
||||
final SecureStorageService storageService;
|
||||
|
||||
HubConnection? _healthConnection;
|
||||
HubConnection? _favoritesConnection;
|
||||
|
||||
bool _isConnected = false;
|
||||
final _statusController = StreamController<bool>.broadcast();
|
||||
final _healthController = StreamController<List<Map<String, dynamic>>>.broadcast();
|
||||
final _favoritePricesController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
bool get isConnected => _isConnected;
|
||||
Stream<bool> get connectionStream => _statusController.stream;
|
||||
Stream<List<Map<String, dynamic>>> get healthStream => _healthController.stream;
|
||||
Stream<Map<String, dynamic>> get favoritePricesStream => _favoritePricesController.stream;
|
||||
|
||||
static const String baseUrl = 'http://localhost:5000';
|
||||
|
||||
SignalRService(this.storageService);
|
||||
|
||||
Future<void> initSignalR() async {
|
||||
if (_isConnected) return;
|
||||
|
||||
try {
|
||||
final token = await storageService.getToken();
|
||||
|
||||
// 1. Connect SystemHealthHub over WebSockets
|
||||
_healthConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/health',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: () async => token,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR Health WS] $message');
|
||||
},
|
||||
),
|
||||
)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_healthConnection!.on('ReceiveSystemHealth', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final List<dynamic> list = arguments.first as List<dynamic>;
|
||||
final mappedList = list.map((item) => Map<String, dynamic>.from(item as Map)).toList();
|
||||
_healthController.add(mappedList);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR Health Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_healthConnection!.onclose((error) {
|
||||
if (kDebugMode) debugPrint('[SignalR Health WS] Closed: $error');
|
||||
});
|
||||
|
||||
await _healthConnection!.start();
|
||||
if (kDebugMode) debugPrint('[SignalR Health WS] Connected via WebSocket to /hubs/health.');
|
||||
|
||||
// 2. Connect FavoritesPriceHub over WebSockets
|
||||
_favoritesConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
'$baseUrl/hubs/favorites-prices',
|
||||
HttpConnectionOptions(
|
||||
accessTokenFactory: () async => token,
|
||||
transport: HttpTransportType.webSockets,
|
||||
logging: (level, message) {
|
||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] $message');
|
||||
},
|
||||
),
|
||||
)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_favoritesConnection!.on('ReceiveFavoritePrices', (arguments) {
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
try {
|
||||
final Map<String, dynamic> priceMap = Map<String, dynamic>.from(arguments.first as Map);
|
||||
_favoritePricesController.add(priceMap);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR Favorites Parsing Error] $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _favoritesConnection!.start();
|
||||
if (kDebugMode) debugPrint('[SignalR Favorites WS] Connected via WebSocket to /hubs/favorites-prices.');
|
||||
|
||||
_isConnected = true;
|
||||
_statusController.add(true);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[SignalR WebSocket Connection Error] $e');
|
||||
_isConnected = false;
|
||||
_statusController.add(false);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void broadcastHealthUpdate(List<Map<String, dynamic>> healthData) {
|
||||
_healthController.add(healthData);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void broadcastFavoritePrices(Map<String, dynamic> priceMap) {
|
||||
_favoritePricesController.add(priceMap);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void disconnect() async {
|
||||
try {
|
||||
await _healthConnection?.stop();
|
||||
await _favoritesConnection?.stop();
|
||||
} catch (_) {}
|
||||
_isConnected = false;
|
||||
_statusController.add(false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_statusController.close();
|
||||
_healthController.close();
|
||||
_favoritePricesController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Secure token and session storage service.
|
||||
class SecureStorageService {
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
static const String _keyToken = 'jwt_token';
|
||||
static const String _keyUserEmail = 'user_email';
|
||||
|
||||
Future<void> saveToken(String token) async {
|
||||
await _storage.write(key: _keyToken, value: token);
|
||||
}
|
||||
|
||||
Future<String?> getToken() async {
|
||||
return await _storage.read(key: _keyToken);
|
||||
}
|
||||
|
||||
Future<void> saveUserEmail(String email) async {
|
||||
await _storage.write(key: _keyUserEmail, value: email);
|
||||
}
|
||||
|
||||
Future<String?> getUserEmail() async {
|
||||
return await _storage.read(key: _keyUserEmail);
|
||||
}
|
||||
|
||||
Future<void> deleteToken() async {
|
||||
await _storage.delete(key: _keyToken);
|
||||
}
|
||||
|
||||
Future<void> deleteUserEmail() async {
|
||||
await _storage.delete(key: _keyUserEmail);
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThemePreset {
|
||||
final String id;
|
||||
final String name;
|
||||
final Brightness brightness;
|
||||
final Color darkBackground;
|
||||
final Color cardSurface;
|
||||
final Color glassSurface;
|
||||
final Color glassBorder;
|
||||
final Color primaryColor;
|
||||
final Color accentColor;
|
||||
final Color accentRed;
|
||||
final Color textPrimary;
|
||||
final Color textSecondary;
|
||||
final Color textMuted;
|
||||
final double borderRadius;
|
||||
final List<BoxShadow> boxShadows;
|
||||
|
||||
const ThemePreset({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.brightness,
|
||||
required this.darkBackground,
|
||||
required this.cardSurface,
|
||||
required this.glassSurface,
|
||||
required this.glassBorder,
|
||||
required this.primaryColor,
|
||||
required this.accentColor,
|
||||
required this.accentRed,
|
||||
required this.textPrimary,
|
||||
required this.textSecondary,
|
||||
required this.textMuted,
|
||||
required this.borderRadius,
|
||||
required this.boxShadows,
|
||||
});
|
||||
|
||||
ThemeData toThemeData() {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
return (isDark ? ThemeData.dark() : ThemeData.light()).copyWith(
|
||||
scaffoldBackgroundColor: darkBackground,
|
||||
primaryColor: primaryColor,
|
||||
colorScheme: isDark
|
||||
? ColorScheme.dark(
|
||||
primary: primaryColor,
|
||||
secondary: accentColor,
|
||||
surface: cardSurface,
|
||||
error: accentRed,
|
||||
)
|
||||
: ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
secondary: accentColor,
|
||||
surface: cardSurface,
|
||||
error: accentRed,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: cardSurface,
|
||||
elevation: isDark ? 0 : 1,
|
||||
centerTitle: false,
|
||||
iconTheme: IconThemeData(color: textPrimary),
|
||||
titleTextStyle: TextStyle(color: textPrimary, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: cardSurface,
|
||||
elevation: isDark ? 0 : 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
side: BorderSide(color: glassBorder, width: 1),
|
||||
),
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(borderRadius + 2),
|
||||
side: BorderSide(color: glassBorder, width: 1),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: glassSurface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderSide: BorderSide(color: glassBorder),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderSide: BorderSide(color: glassBorder),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderSide: BorderSide(color: primaryColor, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic Finlytic Multi-Theme Presets & Tokens Engine.
|
||||
class AppTheme {
|
||||
static const ThemePreset darkClassic = ThemePreset(
|
||||
id: 'dark_classic',
|
||||
name: 'Dark',
|
||||
brightness: Brightness.dark,
|
||||
darkBackground: Color(0xFF0A0E17),
|
||||
cardSurface: Color(0xFF12182B),
|
||||
glassSurface: Color(0x2A1A233A),
|
||||
glassBorder: Color(0x33425980),
|
||||
primaryColor: Color(0xFF00E676),
|
||||
accentColor: Color(0xFF00E5FF),
|
||||
accentRed: Color(0xFFFF5252),
|
||||
textPrimary: Colors.white,
|
||||
textSecondary: Color(0xFFB0BEC5),
|
||||
textMuted: Color(0xFF607D8B),
|
||||
borderRadius: 14.0,
|
||||
boxShadows: [
|
||||
BoxShadow(color: Color(0x1F000000), blurRadius: 10, offset: Offset(0, 4)),
|
||||
],
|
||||
);
|
||||
|
||||
static const ThemePreset darkCyberNeon = ThemePreset(
|
||||
id: 'dark_cyber_neon',
|
||||
name: 'Alternative Dark (Cyber Neon)',
|
||||
brightness: Brightness.dark,
|
||||
darkBackground: Color(0xFF0D0B1E),
|
||||
cardSurface: Color(0xFF161233),
|
||||
glassSurface: Color(0x3A261D52),
|
||||
glassBorder: Color(0x5500FFA3),
|
||||
primaryColor: Color(0xFF00FFA3),
|
||||
accentColor: Color(0xFFFF007A),
|
||||
accentRed: Color(0xFFFF2E93),
|
||||
textPrimary: Color(0xFFF5F3FF),
|
||||
textSecondary: Color(0xFFC4B5FD),
|
||||
textMuted: Color(0xFF8B5CF6),
|
||||
borderRadius: 8.0,
|
||||
boxShadows: [
|
||||
BoxShadow(color: Color(0x3300FFA3), blurRadius: 12, spreadRadius: -2),
|
||||
],
|
||||
);
|
||||
|
||||
static const ThemePreset lightClassic = ThemePreset(
|
||||
id: 'light_classic',
|
||||
name: 'Light',
|
||||
brightness: Brightness.light,
|
||||
darkBackground: Color(0xFFF8FAFC),
|
||||
cardSurface: Color(0xFFFFFFFF),
|
||||
glassSurface: Color(0xFFF1F5F9),
|
||||
glassBorder: Color(0xFFE2E8F0),
|
||||
primaryColor: Color(0xFF059669),
|
||||
accentColor: Color(0xFF0284C7),
|
||||
accentRed: Color(0xFFDC2626),
|
||||
textPrimary: Color(0xFF0F172A),
|
||||
textSecondary: Color(0xFF475569),
|
||||
textMuted: Color(0xFF94A3B8),
|
||||
borderRadius: 16.0,
|
||||
boxShadows: [
|
||||
BoxShadow(color: Color(0x0F000000), blurRadius: 12, offset: Offset(0, 4)),
|
||||
],
|
||||
);
|
||||
|
||||
static const ThemePreset lightWarmSand = ThemePreset(
|
||||
id: 'light_warm_sand',
|
||||
name: 'Alternative Light (Warm Paper)',
|
||||
brightness: Brightness.light,
|
||||
darkBackground: Color(0xFFF5F2EB),
|
||||
cardSurface: Color(0xFFFFFDF9),
|
||||
glassSurface: Color(0xFFEFEADF),
|
||||
glassBorder: Color(0xFFE2D9C8),
|
||||
primaryColor: Color(0xFFD97706),
|
||||
accentColor: Color(0xFF2563EB),
|
||||
accentRed: Color(0xFFE11D48),
|
||||
textPrimary: Color(0xFF272522),
|
||||
textSecondary: Color(0xFF57534E),
|
||||
textMuted: Color(0xFFA8A29E),
|
||||
borderRadius: 12.0,
|
||||
boxShadows: [
|
||||
BoxShadow(color: Color(0x14443422), blurRadius: 8, offset: Offset(0, 3)),
|
||||
],
|
||||
);
|
||||
|
||||
static const ThemePreset nordicMint = ThemePreset(
|
||||
id: 'nordic_mint',
|
||||
name: 'Nordic Mint',
|
||||
brightness: Brightness.light,
|
||||
darkBackground: Color(0xFFEFF6F5),
|
||||
cardSurface: Color(0xFFFFFFFF),
|
||||
glassSurface: Color(0xFFE0F2FE),
|
||||
glassBorder: Color(0xFFCCFBF1),
|
||||
primaryColor: Color(0xFF0D9488),
|
||||
accentColor: Color(0xFF0284C7),
|
||||
accentRed: Color(0xFFF43F5E),
|
||||
textPrimary: Color(0xFF111827),
|
||||
textSecondary: Color(0xFF374151),
|
||||
textMuted: Color(0xFF6B7280),
|
||||
borderRadius: 20.0,
|
||||
boxShadows: [
|
||||
BoxShadow(color: Color(0x0D0F766E), blurRadius: 14, offset: Offset(0, 4)),
|
||||
],
|
||||
);
|
||||
|
||||
static const List<ThemePreset> allPresets = [
|
||||
darkClassic,
|
||||
darkCyberNeon,
|
||||
lightClassic,
|
||||
lightWarmSand,
|
||||
nordicMint,
|
||||
];
|
||||
|
||||
static ThemePreset getPresetById(String? id) {
|
||||
return allPresets.firstWhere(
|
||||
(p) => p.id.toLowerCase() == id?.toLowerCase(),
|
||||
orElse: () => darkClassic,
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy static color mappings for backward compatibility
|
||||
static Color get darkBackground => activePreset.darkBackground;
|
||||
static Color get cardSurface => activePreset.cardSurface;
|
||||
static Color get surfaceDark => activePreset.cardSurface;
|
||||
static Color get glassSurface => activePreset.glassSurface;
|
||||
static Color get glassBorder => activePreset.glassBorder;
|
||||
static Color get primaryEmerald => activePreset.primaryColor;
|
||||
static Color get accentCyan => activePreset.accentColor;
|
||||
static Color get accentRed => activePreset.accentRed;
|
||||
static Color get textPrimary => activePreset.textPrimary;
|
||||
static Color get textSecondary => activePreset.textSecondary;
|
||||
static Color get textMuted => activePreset.textMuted;
|
||||
|
||||
static ThemePreset activePreset = darkClassic;
|
||||
|
||||
static ThemeData get darkTheme => activePreset.toThemeData();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../network/api_client.dart';
|
||||
import 'app_theme.dart';
|
||||
|
||||
class ThemeState {
|
||||
final ThemePreset preset;
|
||||
final bool isLoading;
|
||||
|
||||
const ThemeState({required this.preset, this.isLoading = false});
|
||||
}
|
||||
|
||||
class ThemeCubit extends Cubit<ThemeState> {
|
||||
final ApiClient? apiClient;
|
||||
|
||||
ThemeCubit({this.apiClient}) : super(ThemeState(preset: AppTheme.activePreset));
|
||||
|
||||
Future<void> fetchUserThemePreference() async {
|
||||
if (apiClient == null) return;
|
||||
try {
|
||||
final res = await apiClient!.get('/api/v1/user/preferences');
|
||||
if (res.statusCode == 200 && res.data is Map) {
|
||||
final themeId = res.data['themePreference']?.toString();
|
||||
if (themeId != null && themeId.isNotEmpty) {
|
||||
final preset = AppTheme.getPresetById(themeId);
|
||||
AppTheme.activePreset = preset;
|
||||
emit(ThemeState(preset: preset));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep active preset on network failure
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setTheme(ThemePreset newPreset) async {
|
||||
AppTheme.activePreset = newPreset;
|
||||
emit(ThemeState(preset: newPreset));
|
||||
|
||||
if (apiClient != null) {
|
||||
try {
|
||||
await apiClient!.put(
|
||||
'/api/v1/user/preferences/theme',
|
||||
data: {'themeId': newPreset.id},
|
||||
);
|
||||
} catch (_) {
|
||||
// Silently handle offline preference update
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'app_theme.dart';
|
||||
import 'theme_cubit.dart';
|
||||
|
||||
/// Modal dialog allowing the user to select and preview theme presets.
|
||||
class ThemePickerDialog extends StatelessWidget {
|
||||
const ThemePickerDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: activeTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(activeTheme.borderRadius + 2),
|
||||
side: BorderSide(color: activeTheme.glassBorder, width: 1),
|
||||
),
|
||||
child: Container(
|
||||
width: 480,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined, color: activeTheme.primaryColor),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Design & Theme Auswählen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: AppTheme.allPresets.map((preset) {
|
||||
final isSelected = activeTheme.id == preset.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.read<ThemeCubit>().setTheme(preset);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(preset.borderRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: preset.glassSurface,
|
||||
borderRadius: BorderRadius.circular(preset.borderRadius),
|
||||
border: Border.all(
|
||||
color: isSelected ? preset.primaryColor : preset.glassBorder,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Palette Color Preview Dots
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: preset.darkBackground,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: preset.glassBorder),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: preset.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
preset.name,
|
||||
style: TextStyle(
|
||||
color: preset.textPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Ecken: ${preset.borderRadius.toInt()}px • ${preset.brightness == Brightness.dark ? "Dunkel" : "Hell"}',
|
||||
style: TextStyle(color: preset.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(Icons.check_circle_rounded, color: preset.primaryColor, size: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// Asset utility helpers to map ISIN codes, symbols, and company names to logos and details.
|
||||
class AssetUtils {
|
||||
static final Map<String, String> _isinToNameMap = {
|
||||
'US0378331005': 'Apple Inc.',
|
||||
'US5949181045': 'Microsoft Corp.',
|
||||
'US0231351067': 'Amazon.com Inc.',
|
||||
'US67066G1040': 'NVIDIA Corp.',
|
||||
'US88160R1014': 'Tesla Inc.',
|
||||
'US02079K3059': 'Alphabet Inc.',
|
||||
'US30303M1027': 'Meta Platforms',
|
||||
'DE0007164600': 'SAP SE',
|
||||
'DE0007236101': 'Siemens AG',
|
||||
'DE0008469008': 'Allianz SE',
|
||||
'FR0004125920': 'Amundi',
|
||||
};
|
||||
|
||||
static final Map<String, String> _nameToIsinMap = {
|
||||
'APPLE INC.': 'US0378331005',
|
||||
'APPLE': 'US0378331005',
|
||||
'MICROSOFT CORP.': 'US5949181045',
|
||||
'MICROSOFT': 'US5949181045',
|
||||
'AMAZON.COM INC.': 'US0231351067',
|
||||
'AMAZON': 'US0231351067',
|
||||
'NVIDIA CORP.': 'US67066G1040',
|
||||
'NVIDIA': 'US67066G1040',
|
||||
'TESLA INC.': 'US88160R1014',
|
||||
'TESLA': 'US88160R1014',
|
||||
'ALPHABET INC.': 'US02079K3059',
|
||||
'ALPHABET': 'US02079K3059',
|
||||
'META PLATFORMS': 'US30303M1027',
|
||||
'META': 'US30303M1027',
|
||||
'SAP SE': 'DE0007164600',
|
||||
'SAP': 'DE0007164600',
|
||||
'SIEMENS AG': 'DE0007236101',
|
||||
'SIEMENS': 'DE0007236101',
|
||||
'ALLIANZ SE': 'DE0008469008',
|
||||
'ALLIANZ': 'DE0008469008',
|
||||
'AMUNDI': 'FR0004125920',
|
||||
};
|
||||
|
||||
static final Map<String, String> _imageMap = {};
|
||||
|
||||
/// Registers an ISIN, Name, and optional Logo Image URL.
|
||||
static void registerAsset(String isin, String name, [String? imageUrl]) {
|
||||
final cleanIsin = isin.trim().toUpperCase();
|
||||
final cleanName = name.trim();
|
||||
if (cleanIsin.isNotEmpty && cleanName.isNotEmpty) {
|
||||
_isinToNameMap[cleanIsin] = cleanName;
|
||||
_nameToIsinMap[cleanName.toUpperCase()] = cleanIsin;
|
||||
}
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
final resolved = resolveUrl(imageUrl);
|
||||
if (cleanIsin.isNotEmpty) _imageMap[cleanIsin] = resolved;
|
||||
if (cleanName.isNotEmpty) _imageMap[cleanName.toUpperCase()] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves relative logo URLs (/api/logo/...) to complete backend endpoints.
|
||||
static String resolveUrl(String url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return '${ApiClient.baseUrl}$url';
|
||||
}
|
||||
return '${ApiClient.baseUrl}/$url';
|
||||
}
|
||||
|
||||
/// Resolves an ISIN or symbol to readable asset name.
|
||||
static String getAssetName(String isinOrSymbol) {
|
||||
final key = isinOrSymbol.trim().toUpperCase();
|
||||
if (_isinToNameMap.containsKey(key)) {
|
||||
return _isinToNameMap[key]!;
|
||||
}
|
||||
return isinOrSymbol;
|
||||
}
|
||||
|
||||
/// Resolves an asset name or symbol to ISIN.
|
||||
static String? getIsin(String nameOrSymbol) {
|
||||
final key = nameOrSymbol.trim().toUpperCase();
|
||||
if (_isinToNameMap.containsKey(key)) return key;
|
||||
return _nameToIsinMap[key];
|
||||
}
|
||||
|
||||
/// Returns official local backend logo URL for given symbol/name/ISIN.
|
||||
static String? getLogoUrl(String symbolOrName) {
|
||||
final key = symbolOrName.trim().toUpperCase();
|
||||
if (_imageMap.containsKey(key)) return resolveUrl(_imageMap[key]!);
|
||||
|
||||
final isin = getIsin(key) ?? (key.length == 12 ? key : null);
|
||||
if (isin != null && isin.length == 12) {
|
||||
return '${ApiClient.baseUrl}/api/logo/$isin';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Formatting utility helpers for currency, percentages, dates, and numbers.
|
||||
class Formatters {
|
||||
static final NumberFormat _currencyFormat = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||
static final NumberFormat _percentFormat = NumberFormat.decimalPercentPattern(decimalDigits: 2);
|
||||
|
||||
/// Formats currency values (e.g., $1,234.56).
|
||||
static String formatCurrency(double? value, {String symbol = '\$'}) {
|
||||
if (value == null) return '-';
|
||||
if (symbol == '\$') return _currencyFormat.format(value);
|
||||
return NumberFormat.currency(symbol: symbol, decimalDigits: 2).format(value);
|
||||
}
|
||||
|
||||
/// Formats percentage value with sign (e.g. +3.45%).
|
||||
static String formatPercent(double? value) {
|
||||
if (value == null) return '0.00%';
|
||||
final formatted = _percentFormat.format(value / 100);
|
||||
return value >= 0 ? '+$formatted' : formatted;
|
||||
}
|
||||
|
||||
/// Formats compact numbers (e.g. 1.2M, 3.4B).
|
||||
static String formatCompactNumber(double? value) {
|
||||
if (value == null) return '-';
|
||||
return NumberFormat.compact().format(value);
|
||||
}
|
||||
|
||||
/// Formats ISO date time string to readable short date.
|
||||
static String formatDate(String? isoDate) {
|
||||
if (isoDate == null || isoDate.isEmpty) return '-';
|
||||
try {
|
||||
final dt = DateTime.parse(isoDate).toLocal();
|
||||
return DateFormat('dd.MM.yyyy HH:mm').format(dt);
|
||||
} catch (_) {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats date to day-month format.
|
||||
static String formatShortDate(DateTime dt) {
|
||||
return DateFormat('dd. MMM yyyy').format(dt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Universal time formatting helper to format relative timestamps cleanly for UI displays.
|
||||
class TimeUtils {
|
||||
/// Converts ISO-8601 or DateTime inputs to localized, readable relative time strings.
|
||||
///
|
||||
/// Examples:
|
||||
/// - Under 60 minutes: "Vor 15 Min.", "Vor 42 Min."
|
||||
/// - 1 hour or more: "Vor 1 Std.", "Vor 1 Std. 15 Min.", "Vor 3 Std. 45 Min."
|
||||
/// - Yesterday: "Gestern"
|
||||
/// - Older: "22.07.2026"
|
||||
static String formatRelativeTime(dynamic rawDate) {
|
||||
if (rawDate == null) return '';
|
||||
final str = rawDate.toString().trim();
|
||||
if (str.isEmpty) return '';
|
||||
|
||||
DateTime dt;
|
||||
try {
|
||||
dt = DateTime.parse(str).toLocal();
|
||||
} catch (_) {
|
||||
// Return raw string if already formatted or non-date string
|
||||
return str;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final rawDiff = now.difference(dt);
|
||||
|
||||
// If the timestamp is in the future (e.g. slight clock skew or scraper timezone issues), cap it to 0
|
||||
final duration = rawDiff.isNegative ? Duration.zero : rawDiff;
|
||||
final totalMinutes = duration.inMinutes;
|
||||
|
||||
if (totalMinutes < 1) {
|
||||
return 'Vor 1 Min.';
|
||||
}
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return 'Vor $totalMinutes Min.';
|
||||
}
|
||||
|
||||
final hours = duration.inHours;
|
||||
if (hours < 24) {
|
||||
final remainingMinutes = totalMinutes % 60;
|
||||
if (remainingMinutes == 0) {
|
||||
return 'Vor $hours Std.';
|
||||
} else {
|
||||
return 'Vor $hours Std. $remainingMinutes Min.';
|
||||
}
|
||||
}
|
||||
|
||||
final days = duration.inDays;
|
||||
if (days == 1) {
|
||||
return 'Gestern';
|
||||
} else if (days < 7) {
|
||||
return 'Vor $days Tagen';
|
||||
}
|
||||
|
||||
final dayStr = dt.day.toString().padLeft(2, '0');
|
||||
final monthStr = dt.month.toString().padLeft(2, '0');
|
||||
return '$dayStr.$monthStr.${dt.year}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/asset_utils.dart';
|
||||
|
||||
/// Reusable performance-optimized Asset Logo Widget supporting SVG, PNG, gradient fallbacks, and Hero transitions.
|
||||
class AssetLogoWidget extends StatelessWidget {
|
||||
static final Set<String> _failedUrls = {};
|
||||
|
||||
final String symbolOrName;
|
||||
final String? imageUrl;
|
||||
final double size;
|
||||
final bool enableHero;
|
||||
|
||||
const AssetLogoWidget({
|
||||
super.key,
|
||||
required this.symbolOrName,
|
||||
this.imageUrl,
|
||||
this.size = 32,
|
||||
this.enableHero = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rawUrl = imageUrl ?? AssetUtils.getLogoUrl(symbolOrName);
|
||||
final logoUrl = rawUrl != null && rawUrl.isNotEmpty ? AssetUtils.resolveUrl(rawUrl) : null;
|
||||
final initial = symbolOrName.isNotEmpty ? symbolOrName[0].toUpperCase() : 'A';
|
||||
final colors = _getGradientColors(initial);
|
||||
|
||||
Widget content;
|
||||
|
||||
if (logoUrl != null && logoUrl.isNotEmpty && !_failedUrls.contains(logoUrl)) {
|
||||
final isSvg = logoUrl.toLowerCase().endsWith('.svg') ||
|
||||
logoUrl.contains('traderepublic.com') ||
|
||||
logoUrl.contains('/api/logo/');
|
||||
|
||||
content = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(size * 0.3),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(size * 0.3),
|
||||
color: AppTheme.activePreset.glassSurface,
|
||||
border: Border.all(
|
||||
color: AppTheme.activePreset.glassBorder,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.all(size * 0.1),
|
||||
child: isSvg
|
||||
? SvgPicture.network(
|
||||
logoUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
placeholderBuilder: (context) => _buildFallback(initial, colors),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_failedUrls.add(logoUrl);
|
||||
return _buildFallback(initial, colors);
|
||||
},
|
||||
)
|
||||
: Image.network(
|
||||
logoUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_failedUrls.add(logoUrl);
|
||||
return _buildFallback(initial, colors);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content = _buildFallback(initial, colors);
|
||||
}
|
||||
|
||||
if (enableHero && symbolOrName.isNotEmpty) {
|
||||
return Hero(
|
||||
tag: 'asset_logo_$symbolOrName',
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
Widget _buildFallback(String initial, List<Color> colors) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(size * 0.3),
|
||||
gradient: LinearGradient(
|
||||
colors: colors,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: size * 0.45,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Color> _getGradientColors(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
switch (code % 5) {
|
||||
case 0:
|
||||
return [AppTheme.activePreset.primaryColor, AppTheme.activePreset.accentColor];
|
||||
case 1:
|
||||
return [const Color(0xFF6366F1), const Color(0xFFA855F7)];
|
||||
case 2:
|
||||
return [const Color(0xFFEC4899), const Color(0xFFF43F5E)];
|
||||
case 3:
|
||||
return [const Color(0xFFF59E0B), const Color(0xFFEF4444)];
|
||||
default:
|
||||
return [AppTheme.activePreset.accentColor, const Color(0xFF3B82F6)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Reusable glassmorphic container widget adapting to active ThemePreset.
|
||||
class GlassContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double? borderRadius;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const GlassContainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.margin,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
final effectiveRadius = borderRadius ?? activeTheme.borderRadius;
|
||||
|
||||
final body = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
width: width,
|
||||
height: height,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: activeTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(effectiveRadius),
|
||||
border: Border.all(color: activeTheme.glassBorder, width: 1),
|
||||
boxShadow: activeTheme.boxShadows,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(effectiveRadius),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Smooth Shimmer Loading Skeleton Effect Widget.
|
||||
class ShimmerLoading extends StatefulWidget {
|
||||
final double width;
|
||||
final double height;
|
||||
final double? borderRadius;
|
||||
|
||||
const ShimmerLoading({
|
||||
super.key,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShimmerLoading> createState() => _ShimmerLoadingState();
|
||||
}
|
||||
|
||||
class _ShimmerLoadingState extends State<ShimmerLoading> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
final radius = widget.borderRadius ?? activeTheme.borderRadius;
|
||||
final baseColor = activeTheme.glassSurface;
|
||||
final highlightColor = activeTheme.glassBorder.withValues(alpha: 0.5);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: [
|
||||
(_controller.value - 0.3).clamp(0.0, 1.0),
|
||||
_controller.value,
|
||||
(_controller.value + 0.3).clamp(0.0, 1.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Reusable pill status badge widget for sentiment, trades, or user roles.
|
||||
class StatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData? icon;
|
||||
|
||||
const StatusBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
factory StatusBadge.sentiment(String status, {double? score}) {
|
||||
Color bg = AppTheme.textMuted;
|
||||
if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) {
|
||||
bg = AppTheme.primaryEmerald;
|
||||
} else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) {
|
||||
bg = AppTheme.accentRed;
|
||||
} else if (status.toUpperCase().contains('NEU')) {
|
||||
bg = AppTheme.accentCyan;
|
||||
}
|
||||
return StatusBadge(label: status.toUpperCase(), color: bg);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 12, color: color),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user