Files
Finlytic/FinlyticApp/lib/features/trades/widgets/derivative_picker_modal.dart
T

756 lines
31 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/theme/app_theme.dart';
import '../../../../core/widgets/status_badge.dart';
import '../models/derivative_item_model.dart';
import '../repositories/trade_repository.dart';
class DerivativePickerModal extends StatefulWidget {
final String underlyingIsin;
final String underlyingSymbol;
final String underlyingName;
final String initialSignalType;
final double currentUnderlyingPrice;
const DerivativePickerModal({
super.key,
required this.underlyingIsin,
required this.underlyingSymbol,
this.underlyingName = '',
this.initialSignalType = 'BUY',
this.currentUnderlyingPrice = 0.0,
});
static Future<DerivativeItemModel?> show(
BuildContext context, {
required String underlyingIsin,
required String underlyingSymbol,
String underlyingName = '',
String initialSignalType = 'BUY',
double currentUnderlyingPrice = 0.0,
}) {
return showDialog<DerivativeItemModel>(
context: context,
barrierDismissible: true,
builder: (ctx) => DerivativePickerModal(
underlyingIsin: underlyingIsin,
underlyingSymbol: underlyingSymbol,
underlyingName: underlyingName,
initialSignalType: initialSignalType,
currentUnderlyingPrice: currentUnderlyingPrice,
),
);
}
@override
State<DerivativePickerModal> createState() => _DerivativePickerModalState();
}
class _DerivativePickerModalState extends State<DerivativePickerModal> {
late String _optionType;
double _targetLeverage = 5.0;
final TextEditingController _leverageTextCtrl = TextEditingController(text: '5.0');
String _searchQuery = '';
final TextEditingController _searchCtrl = TextEditingController();
final ScrollController _scrollController = ScrollController();
Timer? _debounceTimer;
List<DerivativeItemModel> _allDerivatives = [];
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMoreData = true;
int _currentPage = 0;
String? _errorMessage;
@override
void initState() {
super.initState();
final isShort = widget.initialSignalType.toUpperCase() == 'SELL' || widget.initialSignalType.toUpperCase() == 'SHORT';
_optionType = isShort ? 'short' : 'long';
_scrollController.addListener(_onScroll);
_fetchDerivatives(reset: true);
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchCtrl.dispose();
_leverageTextCtrl.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 250) {
if (!_isLoading && !_isLoadingMore && _hasMoreData) {
_fetchMoreDerivatives();
}
}
}
String _formatLeverage(double lev) {
if (lev <= 0) return '1x';
if (lev == lev.roundToDouble()) {
return '${lev.toInt()}x';
}
var s = lev.toStringAsFixed(2);
if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return '${s.replaceAll('.', ',')}x';
}
String _formatLeverageNum(double lev) {
if (lev <= 0) return '1';
if (lev == lev.roundToDouble()) {
return lev.toInt().toString();
}
var s = lev.toStringAsFixed(2);
if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return s.replaceAll('.', ',');
}
void _setTargetLeverage(double lev) {
setState(() {
_targetLeverage = lev;
_leverageTextCtrl.text = _formatLeverageNum(lev);
});
_fetchDerivatives(reset: true);
}
Future<void> _fetchDerivatives({bool reset = true, bool forceRefresh = false}) async {
if (reset) {
setState(() {
_isLoading = true;
_isLoadingMore = false;
_hasMoreData = true;
_currentPage = 0;
_errorMessage = null;
});
}
try {
final apiClient = context.read<ApiClient>();
final repo = TradeRepository(apiClient: apiClient);
final isinToUse = widget.underlyingIsin.isNotEmpty ? widget.underlyingIsin : widget.underlyingSymbol;
final results = await repo.fetchDerivatives(
isinToUse,
optionType: _optionType,
targetLeverage: _targetLeverage,
page: _currentPage,
search: _searchQuery.isNotEmpty ? _searchQuery : null,
forceRefresh: forceRefresh,
);
if (mounted) {
setState(() {
if (reset) {
_allDerivatives = results;
} else {
// Append and deduplicate by ISIN
final existingIsins = _allDerivatives.map((d) => d.isin).toSet();
final newItems = results.where((d) => !existingIsins.contains(d.isin)).toList();
_allDerivatives.addAll(newItems);
}
if (results.isEmpty || results.length < 50) {
_hasMoreData = false;
}
_currentPage++;
_isLoading = false;
_isLoadingMore = false;
});
if (reset && _scrollController.hasClients) {
_scrollController.jumpTo(0);
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
_isLoadingMore = false;
});
}
}
}
Future<void> _fetchMoreDerivatives() async {
if (_isLoadingMore || !_hasMoreData) return;
setState(() {
_isLoadingMore = true;
});
await _fetchDerivatives(reset: false);
}
List<DerivativeItemModel> get _filteredDerivatives {
if (_searchQuery.trim().isEmpty) {
return _allDerivatives;
}
final q = _searchQuery.toLowerCase().trim();
return _allDerivatives.where((d) {
final matchIsin = d.isin.toLowerCase().contains(q);
final matchIssuer = d.issuer.toLowerCase().contains(q) || d.issuerDisplayName.toLowerCase().contains(q);
final matchCategory = d.productCategoryName.toLowerCase().contains(q);
final matchBarrier = d.barrier.toString().contains(q);
return matchIsin || matchIssuer || matchCategory || matchBarrier;
}).toList();
}
@override
Widget build(BuildContext context) {
final isLong = _optionType == 'long';
final activeColor = isLong ? AppTheme.primaryEmerald : AppTheme.accentRed;
final filtered = _filteredDerivatives;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Container(
width: 780,
constraints: const BoxConstraints(maxHeight: 820),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.glassBorder),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.6),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 14),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.bolt, color: AppTheme.accentCyan, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Trade Republic Derivate-Finder',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (!_isLoading && _allDerivatives.isNotEmpty)
StatusBadge(
label: '${_allDerivatives.length} Derivate geladen',
color: AppTheme.accentCyan,
),
],
),
const SizedBox(height: 3),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
'Basiswert: ${widget.underlyingName.isNotEmpty ? widget.underlyingName : (widget.underlyingSymbol.isNotEmpty && widget.underlyingSymbol != widget.underlyingIsin ? widget.underlyingSymbol : widget.underlyingIsin)} (${widget.underlyingIsin})',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
if (widget.currentUnderlyingPrice > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white12),
),
child: Text(
'Kurs: €${widget.currentUnderlyingPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
],
),
),
IconButton(
onPressed: () => _fetchDerivatives(reset: true, forceRefresh: true),
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
tooltip: 'Neu von Trade Republic abrufen',
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white54),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
// Controls Bar
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Direction Selector & Search
Row(
children: [
// Direction Toggle
Container(
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_directionButton('Long (Turbo Bull)', 'long', AppTheme.primaryEmerald),
_directionButton('Short (Turbo Bear)', 'short', AppTheme.accentRed),
],
),
),
const SizedBox(width: 12),
// Search Input
Expanded(
child: TextField(
controller: _searchCtrl,
onChanged: (val) => setState(() {
_searchQuery = val;
}),
style: const TextStyle(color: Colors.white, fontSize: 13),
decoration: InputDecoration(
hintText: 'Emittent (HSBC, SocGen...), Barriere, ISIN...',
hintStyle: TextStyle(color: AppTheme.textMuted, fontSize: 12),
prefixIcon: const Icon(Icons.search, size: 16, color: Colors.white54),
filled: true,
fillColor: AppTheme.glassSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.glassBorder)),
),
),
),
],
),
const SizedBox(height: 12),
// Interactive Target-Leverage Control Unit
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
children: [
Text(
'Wunsch-Hebel:',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
// Minus button
_stepButton(Icons.remove, () {
final cur = double.tryParse(_leverageTextCtrl.text.replaceAll(',', '.')) ?? _targetLeverage;
final next = (cur - 1.0 < 1.0) ? 1.0 : (cur - 1.0);
_setTargetLeverage(next);
}, activeColor),
const SizedBox(width: 6),
// Numeric input
SizedBox(
width: 68,
height: 34,
child: TextField(
controller: _leverageTextCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 6),
filled: true,
fillColor: Colors.black.withValues(alpha: 0.35),
suffixText: 'x',
suffixStyle: TextStyle(color: activeColor, fontSize: 12, fontWeight: FontWeight.bold),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: activeColor.withValues(alpha: 0.4))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: Colors.white24)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: activeColor, width: 1.5)),
),
onChanged: (val) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () {
final parsed = double.tryParse(val.replaceAll(',', '.'));
if (parsed != null && parsed > 0) {
_targetLeverage = parsed;
_fetchDerivatives(reset: true);
}
});
},
),
),
const SizedBox(width: 6),
// Plus button
_stepButton(Icons.add, () {
final cur = double.tryParse(_leverageTextCtrl.text.replaceAll(',', '.')) ?? _targetLeverage;
final next = (cur + 1.0 > 150.0) ? 150.0 : (cur + 1.0);
_setTargetLeverage(next);
}, activeColor),
const SizedBox(width: 14),
// Quick Pills
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: [1.0, 2.0, 3.0, 4.0, 5.0, 7.0, 10.0, 15.0, 20.0, 30.0, 50.0, 75.0].map((lev) {
final isSelected = (_targetLeverage - lev).abs() < 0.2;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: InkWell(
onTap: () => _setTargetLeverage(lev),
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? activeColor.withValues(alpha: 0.2) : Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSelected ? activeColor : Colors.white12,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
'${lev.toInt()}x',
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold,
color: isSelected ? activeColor : Colors.white70,
),
),
),
),
);
}).toList(),
),
),
),
],
),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
// Content List (with Streaming Infinite Scroll)
Expanded(
child: _buildListContent(filtered, activeColor),
),
// Streaming Status Footer
if (!_isLoading && filtered.isNotEmpty) ...[
const Divider(color: Colors.white12, height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${filtered.length} Derivate ab ${_formatLeverage(_targetLeverage)} Hebel',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
Row(
children: [
if (_hasMoreData) ...[
Icon(Icons.arrow_downward, size: 14, color: AppTheme.accentCyan),
const SizedBox(width: 4),
Text(
'Scrolle nach unten für höhere Hebel',
style: TextStyle(color: AppTheme.accentCyan, fontSize: 11, fontWeight: FontWeight.bold),
),
] else ...[
Icon(Icons.check_circle, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 4),
Text(
'Alle Derivate dieser Spanne geladen',
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 11, fontWeight: FontWeight.bold),
),
],
],
),
],
),
),
],
],
),
),
);
}
Widget _stepButton(IconData icon, VoidCallback onPressed, Color activeColor) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white12),
),
child: Icon(icon, size: 16, color: Colors.white),
),
);
}
Widget _directionButton(String label, String value, Color color) {
final isSelected = _optionType == value;
return GestureDetector(
onTap: () {
if (_optionType != value) {
setState(() {
_optionType = value;
});
_fetchDerivatives(reset: true);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? color.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: isSelected ? Border.all(color: color.withValues(alpha: 0.6)) : null,
),
child: Text(
label,
style: TextStyle(
color: isSelected ? color : AppTheme.textMuted,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
),
);
}
Widget _buildListContent(List<DerivativeItemModel> filtered, Color activeColor) {
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: AppTheme.accentCyan),
const SizedBox(height: 12),
Text('Lade Live-Derivate ab ${_formatLeverage(_targetLeverage)} Hebel...', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 36, color: AppTheme.accentRed),
const SizedBox(height: 8),
Text('Fehler beim Laden der Derivate', style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(_errorMessage!, style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: () => _fetchDerivatives(reset: true, forceRefresh: true),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Erneut versuchen'),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.accentCyan, foregroundColor: Colors.black),
),
],
),
),
);
}
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 36, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine Derivate für diesen Hebel gefunden', style: TextStyle(color: AppTheme.textSecondary, fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('Wähle einen anderen Hebel (z. B. 2x, 5x, 10x) oder leere die Suche.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
const SizedBox(height: 12),
TextButton(
onPressed: () {
_searchCtrl.clear();
_setTargetLeverage(5.0);
},
child: Text('Zurücksetzen auf 5x Hebel', style: TextStyle(color: AppTheme.accentCyan)),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: filtered.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == filtered.length) {
// Bottom loading indicator when streaming next 50 items
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.accentCyan),
),
const SizedBox(width: 10),
Text('Lade nächste 50 Derivate von Trade Republic...', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
],
),
);
}
final item = filtered[index];
final dist = widget.currentUnderlyingPrice > 0 ? item.distanceToBarrier(widget.currentUnderlyingPrice) : 0.0;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
children: [
// Leverage Badge
Container(
width: 58,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: activeColor.withValues(alpha: 0.4)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatLeverage(item.leverage),
style: TextStyle(color: activeColor, fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'HEBEL',
style: TextStyle(color: activeColor.withValues(alpha: 0.8), fontSize: 9, fontWeight: FontWeight.w600),
),
],
),
),
const SizedBox(width: 12),
// Derivative Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.issuerDisplayName.isNotEmpty ? item.issuerDisplayName : item.issuer,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.nextGenProductCategoryName.isNotEmpty ? item.nextGenProductCategoryName : item.productCategoryName,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text('ISIN: ${item.isin}', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
const SizedBox(width: 10),
if (item.barrier > 0)
Text(
'KO: €${item.barrier.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.orangeAccent, fontSize: 11, fontWeight: FontWeight.bold),
),
if (dist > 0) ...[
const SizedBox(width: 6),
Text(
'(${dist.toStringAsFixed(1)}% Abst.)',
style: TextStyle(color: dist < 5.0 ? AppTheme.accentRed : AppTheme.textMuted, fontSize: 11),
),
],
],
),
],
),
),
// Action Select Button
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(item);
},
style: ElevatedButton.styleFrom(
backgroundColor: activeColor,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Wählen', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
),
],
),
);
},
);
}
}