347 lines
12 KiB
Dart
347 lines
12 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
}
|