Files
Finlytic/FinlyticApp/lib/features/trades/views/trades_feed_screen.dart
T

620 lines
25 KiB
Dart

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<Map<String, dynamic>>? _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<TradeProposalModel> _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<void> _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<void> _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<TradeBloc>();
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<TradeBloc>();
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<TradeBloc>().add(const FetchTrades());
await _loadProposals();
},
color: AppTheme.primaryEmerald,
backgroundColor: AppTheme.cardSurface,
child: BlocBuilder<TradeBloc, TradeState>(
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<TradeBloc>().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<TradeModel> 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<TradeProposalModel> 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<TradeBloc>().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<Widget> _buildProposalsSlivers(List<TradeProposalModel> 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,
),
),
),
],
),
),
);
}
}