feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
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';
|
||||
@@ -31,13 +34,22 @@ class TradesFeedScreen extends StatelessWidget {
|
||||
create: (context) => TradeBloc(
|
||||
repository: TradeRepository(apiClient: apiClient),
|
||||
)..add(const FetchTrades()),
|
||||
child: const _TradesFeedScreenContent(),
|
||||
child: _TradesFeedScreenContent(
|
||||
apiClient: apiClient,
|
||||
signalRService: signalRService,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TradesFeedScreenContent extends StatefulWidget {
|
||||
const _TradesFeedScreenContent();
|
||||
final ApiClient apiClient;
|
||||
final SignalRService signalRService;
|
||||
|
||||
const _TradesFeedScreenContent({
|
||||
required this.apiClient,
|
||||
required this.signalRService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TradesFeedScreenContent> createState() => _TradesFeedScreenContentState();
|
||||
@@ -47,10 +59,129 @@ 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();
|
||||
}
|
||||
|
||||
@@ -60,7 +191,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
TradeExecutionCockpit.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
|
||||
isActive: isActive,
|
||||
onAccept: (dto) {
|
||||
tradeBloc.add(AcceptTradeProposalEvent(dto));
|
||||
@@ -81,7 +212,7 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
TradeClosingCockpit.show(
|
||||
context,
|
||||
trade: trade,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.isin,
|
||||
defaultSymbol: trade.symbol.isNotEmpty ? trade.symbol : trade.underlyingIsin,
|
||||
onClose: (CloseTradeRequestDto dto) {
|
||||
tradeBloc.add(CloseTrade(trade.id, dto: dto));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -103,179 +234,341 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<TradeBloc>().add(const FetchTrades());
|
||||
await _loadProposals();
|
||||
},
|
||||
color: AppTheme.primaryEmerald,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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),
|
||||
Expanded(
|
||||
child: BlocBuilder<TradeBloc, TradeState>(
|
||||
builder: (context, state) {
|
||||
if (state is TradeLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||
}
|
||||
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,
|
||||
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: [
|
||||
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(
|
||||
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()),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald),
|
||||
child: const Text('Erneut Versuchen'),
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||
tooltip: 'Trades Aktualisieren',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is TradeLoaded) {
|
||||
final allTrades = state.trades;
|
||||
final proposals = 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();
|
||||
|
||||
List<TradeModel> filteredList = allTrades;
|
||||
if (_selectedFilter == 'Alle') {
|
||||
filteredList = allTrades.where((t) => !t.isProposed).toList();
|
||||
} else if (_selectedFilter == 'Offen') {
|
||||
filteredList = activeTrades;
|
||||
} else if (_selectedFilter == 'Vorschläge') {
|
||||
filteredList = proposals;
|
||||
} else if (_selectedFilter == 'Geschlossen') {
|
||||
filteredList = closedTrades;
|
||||
} else if (_selectedFilter == 'Abgelehnt') {
|
||||
filteredList = rejectedTrades;
|
||||
}
|
||||
|
||||
if (_searchQuery.trim().isNotEmpty) {
|
||||
final q = _searchQuery.toLowerCase().trim();
|
||||
filteredList = filteredList.where((t) =>
|
||||
t.symbol.toLowerCase().contains(q) ||
|
||||
t.isin.toLowerCase().contains(q) ||
|
||||
t.companyName.toLowerCase().contains(q)).toList();
|
||||
}
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
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: 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: 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('Alle', allTrades.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)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (filteredList.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
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
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
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();
|
||||
},
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -324,4 +617,3 @@ class _TradesFeedScreenContentState extends State<_TradesFeedScreenContent> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user