import 'dart:async'; 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 '../../bot/repositories/bot_repository.dart'; import '../../proposals/views/proposal_decision_screen.dart'; import '../bloc/trade_bloc.dart'; import '../bloc/trade_event.dart'; import '../bloc/trade_state.dart'; import '../models/trade_model.dart'; import '../models/close_trade_request_dto.dart'; import '../repositories/trade_repository.dart'; import '../widgets/trade_card.dart'; import '../widgets/proposed_auto_trades_card.dart'; import '../widgets/trade_execution_cockpit.dart'; import '../widgets/trade_closing_cockpit.dart'; import '../widgets/trade_performance_bar.dart'; class TradesFeedScreen extends StatelessWidget { final ApiClient apiClient; final SignalRService signalRService; const TradesFeedScreen({ super.key, required this.apiClient, required this.signalRService, }); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => TradeBloc( repository: TradeRepository(apiClient: apiClient), )..add(const FetchTrades()), child: _TradesFeedScreenContent( apiClient: apiClient, signalRService: signalRService, ), ); } } class _TradesFeedScreenContent extends StatefulWidget { final ApiClient apiClient; final SignalRService signalRService; const _TradesFeedScreenContent({ required this.apiClient, required this.signalRService, }); @override State<_TradesFeedScreenContent> createState() => _TradesFeedScreenContentState(); } class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> { String _selectedFilter = 'Offen'; String _searchQuery = ''; final TextEditingController _searchCtrl = TextEditingController(); StreamSubscription>? _proposalSubscription; late final BotRepository _botRepository; late final TradeRepository _tradeRepository; // Persistent, REST-backed list of currently active, system-wide trade // proposals (`GET /api/v1/user/trades?status=Proposed`). This is // deliberately independent of `TradeBloc`/`allTrades`: proposals are not // owned by any user, so they never show up in `engine_GetTrades` // (`ActiveTradeDto`/`TradeModel`), which is what `TradeBloc` fetches. Before // this list existed, the only way a proposal ever reached the UI was the // live SignalR push below — a user who wasn't online with the app // connected at the exact moment a proposal was created would never see it, // even though it stays valid for up to 24h server-side. List _proposals = []; bool _proposalsLoading = true; String? _proposalsError; @override void initState() { super.initState(); _botRepository = BotRepository(apiClient: widget.apiClient); _tradeRepository = TradeRepository(apiClient: widget.apiClient); _loadProposals(); // Live proposals pushed by the strategy engine (`/hubs/trade-stream`, // event `ReceiveTradeProposal`) are surfaced immediately as a decision // screen instead of only appearing once persisted in the trades list. _proposalSubscription = widget.signalRService.tradeProposalStream.listen((json) { if (!mounted) return; try { final proposal = TradeProposalModel.fromJson(json); _mergeLiveProposal(proposal); _showProposalDecision(proposal); } catch (_) { // Malformed live payload: ignore rather than show a broken screen. } }); } Future _loadProposals() async { setState(() { _proposalsLoading = true; _proposalsError = null; }); try { final proposals = await _tradeRepository.fetchProposals(); if (!mounted) return; setState(() { _proposals = proposals; _proposalsLoading = false; }); } catch (e) { if (!mounted) return; setState(() { _proposalsError = 'Vorschläge konnten nicht geladen werden.'; _proposalsLoading = false; }); } } /// Inserts/updates a proposal received live over SignalR into the /// persistent list without waiting for a full refetch, so the list stays /// consistent if it happens to be visible when the push arrives. void _mergeLiveProposal(TradeProposalModel proposal) { setState(() { final idx = _proposals.indexWhere((p) => p.proposalId == proposal.proposalId); if (idx >= 0) { _proposals[idx] = proposal; } else { _proposals = [proposal, ..._proposals]; } }); } void _showProposalDecision(TradeProposalModel proposal) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProposalDecisionScreen( proposal: proposal, onExecuteBot: () => _executeProposalViaBot(proposal), onManualTrade: () { Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Manuelle Eröffnung: Bitte über die Order-Maske deines Brokers ausführen.'), behavior: SnackBarBehavior.floating, ), ); }, ), ), ); } Future _executeProposalViaBot(TradeProposalModel proposal) async { Navigator.of(context).pop(); try { await _botRepository.executeProposal(proposal.proposalId); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Vorschlag für ${proposal.symbol} an den Bot übergeben.'), backgroundColor: AppTheme.primaryEmerald, behavior: SnackBarBehavior.floating, ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Fehler bei der Bot-Übergabe: $e'), backgroundColor: AppTheme.accentRed, behavior: SnackBarBehavior.floating, ), ); } } @override void dispose() { _searchCtrl.dispose(); _proposalSubscription?.cancel(); super.dispose(); } void _handleAcceptProposal(BuildContext context, TradeModel trade, {bool isActive = false}) { final tradeBloc = context.read(); TradeExecutionCockpit.show( context, trade: trade, defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin, isActive: isActive, onAccept: (dto) { tradeBloc.add(AcceptTradeProposalEvent(dto)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(isActive ? 'Einstellungen für ${trade.symbol} gespeichert!' : 'Trade für ${trade.symbol} eröffnet!'), backgroundColor: AppTheme.primaryEmerald, behavior: SnackBarBehavior.floating, ), ); }, ); } void _handleCloseTrade(BuildContext context, TradeModel trade) { final tradeBloc = context.read(); TradeClosingCockpit.show( context, trade: trade, defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin, onClose: (CloseTradeRequestDto dto) { tradeBloc.add(CloseTrade(trade.id, dto: dto)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Position ${trade.symbol} geschlossen! Realisierter Verkaufskurs: €${dto.userExitPrice.toStringAsFixed(2)}'), backgroundColor: AppTheme.primaryEmerald, behavior: SnackBarBehavior.floating, ), ); }, ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.darkBackground, body: SafeArea( child: RefreshIndicator( onRefresh: () async { context.read().add(const FetchTrades()); await _loadProposals(); }, color: AppTheme.primaryEmerald, backgroundColor: AppTheme.cardSurface, child: BlocBuilder( builder: (context, state) { if (state is TradeLoading) { return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)); } if (state is TradeError) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 48, color: AppTheme.accentRed), const SizedBox(height: 12), Text(state.message, style: TextStyle(color: AppTheme.textMuted)), const SizedBox(height: 16), ElevatedButton( onPressed: () => context.read().add(const FetchTrades()), style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald), child: const Text('Erneut Versuchen'), ), ], ), ); } if (state is TradeLoaded) { // `allTrades` comes from `GET /api/v1/user/trades`, which only ever // returns trades belonging to the signed-in user (own proposals, // own open positions, own history) — never other users' or // system-wide data. `bookedTrades` excludes still-open proposals so // the "Trades" chip's count matches what it actually displays, // instead of silently disagreeing with the "Vorschläge" chip. final allTrades = state.trades; final proposals = allTrades.where((t) => t.isProposed).toList(); final bookedTrades = allTrades.where((t) => !t.isProposed).toList(); final activeTrades = allTrades.where((t) => t.isActive).toList(); final closedTrades = allTrades.where((t) => t.isClosed).toList(); final rejectedTrades = allTrades.where((t) => t.isRejected).toList(); // The "Vorschläge" chip does NOT render `proposals` (derived // from `allTrades`/`TradeModel`): proposals are system-wide // opportunities not owned by any user, so `engine_GetTrades` // (what `TradeBloc`/`allTrades` fetches) never returns them — // that list is structurally always empty. The real, // persistent set of active proposals is `_proposals`, fetched // separately via `GET /api/v1/user/trades?status=Proposed` // and rendered by `_buildProposalsSliver` below. final showingProposalsTab = _selectedFilter == 'Vorschläge'; List filteredList = allTrades; if (_selectedFilter == 'Trades') { filteredList = bookedTrades; } else if (_selectedFilter == 'Offen') { filteredList = activeTrades; } else if (_selectedFilter == 'Vorschläge') { filteredList = const []; } else if (_selectedFilter == 'Geschlossen') { filteredList = closedTrades; } else if (_selectedFilter == 'Abgelehnt') { filteredList = rejectedTrades; } List filteredProposals = _proposals; if (_searchQuery.trim().isNotEmpty) { final q = _searchQuery.toLowerCase().trim(); filteredList = filteredList.where((t) => t.symbol.toLowerCase().contains(q) || t.underlyingIsin.toLowerCase().contains(q)).toList(); filteredProposals = filteredProposals.where((p) => p.symbol.toLowerCase().contains(q) || p.underlyingIsin.toLowerCase().contains(q)).toList(); } return CustomScrollView( slivers: [ SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), sliver: SliverList( delegate: SliverChildListDelegate([ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Live Portfolio & Trades', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), ), const SizedBox(height: 2), Text( 'KI-Guardian Überwachung, Drift-Radar & Order-Cockpit', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), ], ), IconButton( onPressed: () => context.read().add(const FetchTrades()), icon: const Icon(Icons.refresh, color: Colors.white70), tooltip: 'Trades Aktualisieren', ), ], ), const SizedBox(height: 16), TradePerformanceBar( activeTrades: activeTrades, allTrades: allTrades, proposals: proposals, ), ProposedAutoTradesCard( proposals: proposals, onAcceptProposal: (trade) => _handleAcceptProposal(context, trade), ), Row( children: [ Expanded( child: TextField( controller: _searchCtrl, onChanged: (val) => setState(() => _searchQuery = val), style: const TextStyle(color: Colors.white, fontSize: 13), decoration: InputDecoration( hintText: 'Suche nach Symbol, ISIN oder Name...', hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 13), prefixIcon: const Icon(Icons.search, size: 18, color: Colors.white54), filled: true, fillColor: Colors.white.withValues(alpha: 0.05), contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1))), ), ), ), ], ), const SizedBox(height: 12), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _filterChip('Offen', activeTrades.length), _filterChip('Vorschläge', _proposals.length), _filterChip('Geschlossen', closedTrades.length), _filterChip('Abgelehnt', rejectedTrades.length), _filterChip('Trades', bookedTrades.length), ], ), ), const SizedBox(height: 16), ]), ), ), if (showingProposalsTab) ..._buildProposalsSlivers(filteredProposals) else if (filteredList.isEmpty) SliverPadding( padding: const EdgeInsets.symmetric(vertical: 40), sliver: SliverToBoxAdapter( child: Center( child: Column( children: [ Icon(Icons.inbox, size: 40, color: AppTheme.textMuted), const SizedBox(height: 8), Text('Keine Trades in der Kategorie "$_selectedFilter" vorhanden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), ], ), ), ), ) else SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverList.builder( itemCount: filteredList.length, itemBuilder: (context, index) { final trade = filteredList[index]; return TradeCard( trade: trade, onAccept: () => _handleAcceptProposal(context, trade), onSettings: () => _handleAcceptProposal(context, trade, isActive: true), onClose: () => _handleCloseTrade(context, trade), ); }, ), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ); } return const SizedBox.shrink(); }, ), ), ), ); } /// Builds the sliver(s) for the "Vorschläge" tab: a persistent, REST-backed /// list of every currently active proposal (see `_proposals`/`_loadProposals`), /// not just whatever happened to arrive live over SignalR while this screen /// was open. Loading/error/empty are all explicit states (Rules.md §4) — /// there is no silent "nothing shown" case. List _buildProposalsSlivers(List proposals) { if (_proposalsLoading) { return [ SliverPadding( padding: const EdgeInsets.symmetric(vertical: 40), sliver: SliverToBoxAdapter( child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)), ), ), ]; } if (_proposalsError != null) { return [ SliverPadding( padding: const EdgeInsets.symmetric(vertical: 40), sliver: SliverToBoxAdapter( child: Center( child: Column( children: [ Icon(Icons.error_outline, size: 40, color: AppTheme.accentRed), const SizedBox(height: 8), Text(_proposalsError!, style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), const SizedBox(height: 12), ElevatedButton( onPressed: _loadProposals, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald), child: const Text('Erneut Versuchen'), ), ], ), ), ), ), ]; } if (proposals.isEmpty) { return [ SliverPadding( padding: const EdgeInsets.symmetric(vertical: 40), sliver: SliverToBoxAdapter( child: Center( child: Column( children: [ Icon(Icons.inbox, size: 40, color: AppTheme.textMuted), const SizedBox(height: 8), Text('Keine aktiven Vorschläge.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), ], ), ), ), ), ]; } return [ SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverList.builder( itemCount: proposals.length, itemBuilder: (context, index) => _buildProposalListCard(proposals[index]), ), ), ]; } Widget _buildProposalListCard(TradeProposalModel proposal) { final isLong = proposal.isLong; final signalColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed; final symbol = proposal.symbol.isNotEmpty ? proposal.symbol : proposal.underlyingIsin; final expiresAtUtc = proposal.expiresAtUtc; final remaining = expiresAtUtc?.difference(DateTime.now().toUtc()); final expiryLabel = remaining == null ? 'Ablauf unbekannt' : remaining.isNegative ? 'Abgelaufen' : remaining.inHours >= 1 ? 'Läuft in ${remaining.inHours}h ab' : 'Läuft in ${remaining.inMinutes}min ab'; return Card( margin: const EdgeInsets.only(bottom: 10), color: AppTheme.cardSurface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), side: BorderSide(color: Colors.white.withValues(alpha: 0.08)), ), child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () => _showProposalDecision(proposal), child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: signalColor.withValues(alpha: 0.15), border: Border.all(color: signalColor.withValues(alpha: 0.5)), ), child: Text( isLong ? 'LONG' : 'SHORT', style: TextStyle(color: signalColor, fontWeight: FontWeight.bold, fontSize: 11), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(symbol, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), const SizedBox(height: 2), Text( 'Einstieg €${proposal.entryPrice.toStringAsFixed(2)} · Score ${proposal.compositeScore.toStringAsFixed(0)} · $expiryLabel', style: TextStyle(color: AppTheme.textMuted, fontSize: 11), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 8), Icon(Icons.chevron_right, color: AppTheme.textMuted, size: 20), ], ), ), ), ); } Widget _filterChip(String label, int count) { final isSelected = _selectedFilter == label; return GestureDetector( onTap: () => setState(() => _selectedFilter = label), child: Container( margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(20), border: Border.all(color: isSelected ? AppTheme.primaryEmerald : Colors.white.withValues(alpha: 0.12)), ), child: Row( children: [ Text( label, style: TextStyle( color: isSelected ? Colors.black : Colors.white, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontSize: 12, ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: isSelected ? Colors.black.withValues(alpha: 0.2) : Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Text( '$count', style: TextStyle( color: isSelected ? Colors.black : Colors.white70, fontSize: 10, fontWeight: FontWeight.bold, ), ), ), ], ), ), ); } }