feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/recent_setup_model.dart';
|
||||
import '../models/watchlist_entry_model.dart';
|
||||
import '../repositories/admin_repository.dart';
|
||||
|
||||
/// Card sitting next to "AUFSCHLÜSSELUNG NACH GRUND" showing how many assets
|
||||
/// FinlyticTechnicals' background scanner is currently watching. Tapping opens
|
||||
/// a dialog listing every entry — this directly answers "is anything even
|
||||
/// being checked in the background right now", independent of whether any of
|
||||
/// those checks have (yet) produced a proposal-worthy evaluation the engine
|
||||
/// history tab above would show.
|
||||
class WatchlistCard extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const WatchlistCard({super.key, required this.apiClient});
|
||||
|
||||
@override
|
||||
State<WatchlistCard> createState() => _WatchlistCardState();
|
||||
}
|
||||
|
||||
class _WatchlistCardState extends State<WatchlistCard> {
|
||||
late final AdminRepository _repository = AdminRepository(apiClient: widget.apiClient);
|
||||
List<WatchlistEntryModel>? _entries;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final entries = await _repository.fetchWatchlist();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _showDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => _WatchlistDialog(repository: _repository, initialEntries: _entries ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = _entries?.length;
|
||||
final value = _error != null ? '—' : (count?.toString() ?? '…');
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
onTap: _showDialog,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('WATCHLIST', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const Spacer(),
|
||||
Icon(Icons.list_alt_rounded, size: 16, color: AppTheme.accentCyan),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(value, style: TextStyle(fontSize: 22, color: AppTheme.textPrimary, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text('überwachte Assets', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 11)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WatchlistDialog extends StatefulWidget {
|
||||
final AdminRepository repository;
|
||||
final List<WatchlistEntryModel> initialEntries;
|
||||
|
||||
const _WatchlistDialog({required this.repository, required this.initialEntries});
|
||||
|
||||
@override
|
||||
State<_WatchlistDialog> createState() => _WatchlistDialogState();
|
||||
}
|
||||
|
||||
class _WatchlistDialogState extends State<_WatchlistDialog> {
|
||||
late List<WatchlistEntryModel> _entries = widget.initialEntries;
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
final fresh = await widget.repository.fetchWatchlist();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = fresh;
|
||||
_refreshing = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime utc) {
|
||||
final local = utc.toLocal();
|
||||
final d = local.day.toString().padLeft(2, '0');
|
||||
final m = local.month.toString().padLeft(2, '0');
|
||||
final h = local.hour.toString().padLeft(2, '0');
|
||||
final min = local.minute.toString().padLeft(2, '0');
|
||||
return '$d.$m.${local.year} $h:$min';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeTheme = AppTheme.activePreset;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: activeTheme.cardSurface,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
|
||||
child: GlassContainer(
|
||||
borderRadius: 20,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.list_alt_rounded, color: AppTheme.accentCyan, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Watchlist (${_entries.length})', style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _refreshing ? null : _refresh,
|
||||
icon: _refreshing
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||
tooltip: 'Neu laden',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Assets, die FinlyticTechnicals derzeit im Hintergrund fortlaufend überprüft. Eintrag antippen für die letzten Bewertungen.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_entries.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text('Die Watchlist ist derzeit leer.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white12),
|
||||
itemBuilder: (context, index) => _WatchlistEntryTile(
|
||||
entry: _entries[index],
|
||||
repository: widget.repository,
|
||||
formatTimestamp: _formatTimestamp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WatchlistEntryTile extends StatefulWidget {
|
||||
final WatchlistEntryModel entry;
|
||||
final AdminRepository repository;
|
||||
final String Function(DateTime) formatTimestamp;
|
||||
|
||||
const _WatchlistEntryTile({required this.entry, required this.repository, required this.formatTimestamp});
|
||||
|
||||
@override
|
||||
State<_WatchlistEntryTile> createState() => _WatchlistEntryTileState();
|
||||
}
|
||||
|
||||
class _WatchlistEntryTileState extends State<_WatchlistEntryTile> {
|
||||
List<RecentSetupModel>? _history;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
if (_history != null || _loading) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final history = await widget.repository.fetchWatchlistEntryHistory(widget.entry.isin);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_history = history;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entry = widget.entry;
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
onExpansionChanged: (expanded) {
|
||||
if (expanded) _loadHistory();
|
||||
},
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.symbol?.isNotEmpty == true ? entry.symbol! : entry.isin,
|
||||
style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (entry.source != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentCyan.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(entry.source!.label, style: TextStyle(color: AppTheme.accentCyan, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'Seit ${widget.formatTimestamp(entry.addedAtUtc)}'
|
||||
'${entry.expiresAtUtc != null ? ' · Läuft ab ${widget.formatTimestamp(entry.expiresAtUtc!)}' : ''}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildHistoryBody(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryBody() {
|
||||
if (_loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
);
|
||||
}
|
||||
if (_error != null) {
|
||||
return Text(_error!, style: TextStyle(color: AppTheme.accentRed, fontSize: 12));
|
||||
}
|
||||
final history = _history ?? const [];
|
||||
if (history.isEmpty) {
|
||||
return Text(
|
||||
'Noch keine technische Bewertung für dieses Asset erfasst.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('LETZTE BEWERTUNGEN', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 6),
|
||||
...history.map((setup) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(widget.formatTimestamp(setup.createdAt), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(setup.strategyName, style: TextStyle(color: AppTheme.textPrimary, fontSize: 11), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
setup.qualityScore.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
color: setup.isTopPick ? AppTheme.primaryEmerald : Colors.amber,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user