feat(app): add evaluation history, bot control panel, simulation visualizer, and vendored signalr_core
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../repositories/admin_repository.dart';
|
||||
import 'admin_evaluation_history_event.dart';
|
||||
import 'admin_evaluation_history_state.dart';
|
||||
|
||||
export 'admin_evaluation_history_event.dart';
|
||||
export 'admin_evaluation_history_state.dart';
|
||||
|
||||
class AdminEvaluationHistoryBloc extends Bloc<AdminEvaluationHistoryEvent, AdminEvaluationHistoryState> {
|
||||
final AdminRepository repository;
|
||||
|
||||
AdminEvaluationHistoryBloc({required this.repository}) : super(AdminEvaluationHistoryInitial()) {
|
||||
on<FetchEvaluationHistory>(_onFetch);
|
||||
}
|
||||
|
||||
Future<void> _onFetch(FetchEvaluationHistory event, Emitter<AdminEvaluationHistoryState> emit) async {
|
||||
emit(AdminEvaluationHistoryLoading());
|
||||
try {
|
||||
final response = await repository.fetchEvaluationHistory(
|
||||
fromUtc: event.fromUtc,
|
||||
toUtc: event.toUtc,
|
||||
outcome: event.outcome,
|
||||
triggerSource: event.triggerSource,
|
||||
search: event.search,
|
||||
page: event.page,
|
||||
pageSize: event.pageSize,
|
||||
);
|
||||
emit(AdminEvaluationHistoryLoaded(response: response, page: event.page, pageSize: event.pageSize));
|
||||
} catch (e) {
|
||||
emit(AdminEvaluationHistoryError(e.toString().replaceFirst('Exception: ', '')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
|
||||
abstract class AdminEvaluationHistoryEvent extends Equatable {
|
||||
const AdminEvaluationHistoryEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Fetches (or re-fetches) one page of evaluation history for the given filter
|
||||
/// set. There is deliberately no separate "change page" event — every fetch is
|
||||
/// a full filter snapshot, so the bloc never has to guess which filters were
|
||||
/// active on a previously-loaded page when the caller asks for the next one.
|
||||
class FetchEvaluationHistory extends AdminEvaluationHistoryEvent {
|
||||
final DateTime? fromUtc;
|
||||
final DateTime? toUtc;
|
||||
final OutcomeReason? outcome;
|
||||
final TriggerSource? triggerSource;
|
||||
final String? search;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const FetchEvaluationHistory({
|
||||
this.fromUtc,
|
||||
this.toUtc,
|
||||
this.outcome,
|
||||
this.triggerSource,
|
||||
this.search,
|
||||
this.page = 1,
|
||||
this.pageSize = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [fromUtc, toUtc, outcome, triggerSource, search, page, pageSize];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/evaluation_history_response_model.dart';
|
||||
|
||||
abstract class AdminEvaluationHistoryState extends Equatable {
|
||||
const AdminEvaluationHistoryState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AdminEvaluationHistoryInitial extends AdminEvaluationHistoryState {}
|
||||
|
||||
class AdminEvaluationHistoryLoading extends AdminEvaluationHistoryState {}
|
||||
|
||||
class AdminEvaluationHistoryLoaded extends AdminEvaluationHistoryState {
|
||||
final EvaluationHistoryResponseModel response;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const AdminEvaluationHistoryLoaded({
|
||||
required this.response,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
bool get hasPreviousPage => page > 1;
|
||||
|
||||
bool get hasNextPage => page * pageSize < response.totalCount;
|
||||
|
||||
int get rangeStart => response.totalCount == 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
|
||||
int get rangeEnd {
|
||||
final end = page * pageSize;
|
||||
return end > response.totalCount ? response.totalCount : end;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [response, page, pageSize];
|
||||
}
|
||||
|
||||
class AdminEvaluationHistoryError extends AdminEvaluationHistoryState {
|
||||
final String message;
|
||||
|
||||
const AdminEvaluationHistoryError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_enums.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistoryEntryDto` — one row
|
||||
/// of `GET /api/v1/admin/evaluations`. Every score field is the real,
|
||||
/// already-computed value the server persisted (including the honest 0/default
|
||||
/// values recorded for [OutcomeReason.noTechnicalSetups]) — nothing here is
|
||||
/// fabricated client-side (Rules.md §4).
|
||||
class EvaluationHistoryEntryModel extends Equatable {
|
||||
final String id;
|
||||
final String isin;
|
||||
final String symbol;
|
||||
final double technicalScore;
|
||||
final double sentimentScore;
|
||||
final double fundamentalScore;
|
||||
final double compositeOpportunityScore;
|
||||
final double reliabilityBonus;
|
||||
final bool passedEarningsLockout;
|
||||
final int? daysToNextEarnings;
|
||||
final bool passedDividendGate;
|
||||
final int? daysToNextExDividend;
|
||||
final UniverseSource? universeSource;
|
||||
final DateTime? universeEnteredAtUtc;
|
||||
final bool passedSimulationVeto;
|
||||
final bool passedAiValidation;
|
||||
final String aiThesisSummary;
|
||||
final OutcomeReason outcomeReason;
|
||||
final TriggerSource triggerSource;
|
||||
final String? triggeredByUserId;
|
||||
final String? proposalId;
|
||||
final DateTime evaluatedAtUtc;
|
||||
|
||||
const EvaluationHistoryEntryModel({
|
||||
required this.id,
|
||||
required this.isin,
|
||||
required this.symbol,
|
||||
required this.technicalScore,
|
||||
required this.sentimentScore,
|
||||
required this.fundamentalScore,
|
||||
required this.compositeOpportunityScore,
|
||||
required this.reliabilityBonus,
|
||||
required this.passedEarningsLockout,
|
||||
this.daysToNextEarnings,
|
||||
required this.passedDividendGate,
|
||||
this.daysToNextExDividend,
|
||||
this.universeSource,
|
||||
this.universeEnteredAtUtc,
|
||||
required this.passedSimulationVeto,
|
||||
required this.passedAiValidation,
|
||||
required this.aiThesisSummary,
|
||||
required this.outcomeReason,
|
||||
required this.triggerSource,
|
||||
this.triggeredByUserId,
|
||||
this.proposalId,
|
||||
required this.evaluatedAtUtc,
|
||||
});
|
||||
|
||||
/// True exactly when this evaluation resulted in a trade proposal.
|
||||
bool get hasProposal => proposalId != null && proposalId!.isNotEmpty;
|
||||
|
||||
factory EvaluationHistoryEntryModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
DateTime parseDate(dynamic val) {
|
||||
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
return DateTime.tryParse(val.toString())?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
return EvaluationHistoryEntryModel(
|
||||
id: json['id']?.toString() ?? '',
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString() ?? '',
|
||||
technicalScore: parseDbl(json['technicalScore']),
|
||||
sentimentScore: parseDbl(json['sentimentScore']),
|
||||
fundamentalScore: parseDbl(json['fundamentalScore']),
|
||||
compositeOpportunityScore: parseDbl(json['compositeOpportunityScore']),
|
||||
reliabilityBonus: parseDbl(json['reliabilityBonus']),
|
||||
passedEarningsLockout: json['passedEarningsLockout'] == true,
|
||||
daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null,
|
||||
passedDividendGate: json['passedDividendGate'] == true,
|
||||
daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null,
|
||||
universeSource: UniverseSource.fromJson(json['universeSource']?.toString()),
|
||||
universeEnteredAtUtc: json['universeEnteredAtUtc'] == null ? null : parseDate(json['universeEnteredAtUtc']),
|
||||
passedSimulationVeto: json['passedSimulationVeto'] == true,
|
||||
passedAiValidation: json['passedAiValidation'] == true,
|
||||
aiThesisSummary: json['aiThesisSummary']?.toString() ?? '',
|
||||
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
|
||||
triggerSource: TriggerSource.fromJson(json['triggerSource']?.toString()),
|
||||
triggeredByUserId: json['triggeredByUserId']?.toString(),
|
||||
proposalId: json['proposalId']?.toString(),
|
||||
evaluatedAtUtc: parseDate(json['evaluatedAtUtc']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
isin,
|
||||
symbol,
|
||||
technicalScore,
|
||||
sentimentScore,
|
||||
fundamentalScore,
|
||||
compositeOpportunityScore,
|
||||
reliabilityBonus,
|
||||
passedEarningsLockout,
|
||||
daysToNextEarnings,
|
||||
passedDividendGate,
|
||||
daysToNextExDividend,
|
||||
universeSource,
|
||||
universeEnteredAtUtc,
|
||||
passedSimulationVeto,
|
||||
passedAiValidation,
|
||||
aiThesisSummary,
|
||||
outcomeReason,
|
||||
triggerSource,
|
||||
triggeredByUserId,
|
||||
proposalId,
|
||||
evaluatedAtUtc,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
|
||||
/// Mirrors `FinlyticCore.Dtos.Trading.OutcomeReason` (`TradeEnums.cs`), which the
|
||||
/// `/api/v1/admin/evaluations` endpoint serializes as a `JsonStringEnumConverter`
|
||||
/// string using the exact C# member name (e.g. `"Approved"`, `"BelowScoreThreshold"`).
|
||||
///
|
||||
/// [unknown] is the fallback both for the server's own `Unknown = 0` default (an
|
||||
/// honest "we don't know" rather than a fabricated reason, Rules.md §4) and for any
|
||||
/// future server-side member this client doesn't recognize yet.
|
||||
enum OutcomeReason {
|
||||
unknown,
|
||||
approved,
|
||||
belowScoreThreshold,
|
||||
earningsLockout,
|
||||
simulationVeto,
|
||||
aiRejected,
|
||||
noTechnicalSetups,
|
||||
duplicateActiveProposal,
|
||||
dividendGate;
|
||||
|
||||
static OutcomeReason fromJson(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Approved':
|
||||
return OutcomeReason.approved;
|
||||
case 'BelowScoreThreshold':
|
||||
return OutcomeReason.belowScoreThreshold;
|
||||
case 'EarningsLockout':
|
||||
return OutcomeReason.earningsLockout;
|
||||
case 'SimulationVeto':
|
||||
return OutcomeReason.simulationVeto;
|
||||
case 'AiRejected':
|
||||
return OutcomeReason.aiRejected;
|
||||
case 'NoTechnicalSetups':
|
||||
return OutcomeReason.noTechnicalSetups;
|
||||
case 'DuplicateActiveProposal':
|
||||
return OutcomeReason.duplicateActiveProposal;
|
||||
case 'DividendGate':
|
||||
return OutcomeReason.dividendGate;
|
||||
case 'Unknown':
|
||||
default:
|
||||
return OutcomeReason.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact server-side enum member name. `[FromQuery] OutcomeReason?` on
|
||||
/// `AdminEvaluationHistoryController` model-binds a bare enum member name from
|
||||
/// the query string (ASP.NET Core's default `Enum.TryParse`-based binder), not a
|
||||
/// JSON string — so this is what must be sent back as the `outcome` filter value.
|
||||
String toApiValue() {
|
||||
switch (this) {
|
||||
case OutcomeReason.approved:
|
||||
return 'Approved';
|
||||
case OutcomeReason.belowScoreThreshold:
|
||||
return 'BelowScoreThreshold';
|
||||
case OutcomeReason.earningsLockout:
|
||||
return 'EarningsLockout';
|
||||
case OutcomeReason.simulationVeto:
|
||||
return 'SimulationVeto';
|
||||
case OutcomeReason.aiRejected:
|
||||
return 'AiRejected';
|
||||
case OutcomeReason.noTechnicalSetups:
|
||||
return 'NoTechnicalSetups';
|
||||
case OutcomeReason.duplicateActiveProposal:
|
||||
return 'DuplicateActiveProposal';
|
||||
case OutcomeReason.dividendGate:
|
||||
return 'DividendGate';
|
||||
case OutcomeReason.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case OutcomeReason.approved:
|
||||
return 'Freigegeben';
|
||||
case OutcomeReason.belowScoreThreshold:
|
||||
return 'Score zu niedrig';
|
||||
case OutcomeReason.earningsLockout:
|
||||
return 'Earnings-Sperre';
|
||||
case OutcomeReason.simulationVeto:
|
||||
return 'Simulation-Veto';
|
||||
case OutcomeReason.aiRejected:
|
||||
return 'KI abgelehnt';
|
||||
case OutcomeReason.noTechnicalSetups:
|
||||
return 'Kein Setup';
|
||||
case OutcomeReason.duplicateActiveProposal:
|
||||
return 'Bereits aktiver Vorschlag';
|
||||
case OutcomeReason.dividendGate:
|
||||
return 'Dividend-Sperre';
|
||||
case OutcomeReason.unknown:
|
||||
return 'Unbekannt';
|
||||
}
|
||||
}
|
||||
|
||||
/// Color-coding for the history-list badge, reusing only colors already
|
||||
/// established elsewhere in the app (`AppTheme.primaryEmerald`/`accentRed` plus
|
||||
/// the `Colors.amber`/`Colors.purpleAccent` already used by
|
||||
/// `EvaluationScoreBreakdownSheet`) rather than introducing a new palette.
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case OutcomeReason.approved:
|
||||
return AppTheme.primaryEmerald;
|
||||
case OutcomeReason.aiRejected:
|
||||
case OutcomeReason.simulationVeto:
|
||||
return AppTheme.accentRed;
|
||||
case OutcomeReason.belowScoreThreshold:
|
||||
case OutcomeReason.earningsLockout:
|
||||
case OutcomeReason.duplicateActiveProposal:
|
||||
case OutcomeReason.dividendGate:
|
||||
return Colors.amber;
|
||||
case OutcomeReason.noTechnicalSetups:
|
||||
case OutcomeReason.unknown:
|
||||
return AppTheme.textMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `FinlyticCore.Dtos.Trading.TriggerSource`.
|
||||
enum TriggerSource {
|
||||
unknown,
|
||||
automatic,
|
||||
manual;
|
||||
|
||||
static TriggerSource fromJson(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Automatic':
|
||||
return TriggerSource.automatic;
|
||||
case 'Manual':
|
||||
return TriggerSource.manual;
|
||||
case 'Unknown':
|
||||
default:
|
||||
return TriggerSource.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
String toApiValue() {
|
||||
switch (this) {
|
||||
case TriggerSource.automatic:
|
||||
return 'Automatic';
|
||||
case TriggerSource.manual:
|
||||
return 'Manual';
|
||||
case TriggerSource.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case TriggerSource.automatic:
|
||||
return 'Automatisch';
|
||||
case TriggerSource.manual:
|
||||
return 'Manuell';
|
||||
case TriggerSource.unknown:
|
||||
return 'Unbekannt';
|
||||
}
|
||||
}
|
||||
|
||||
Color get color {
|
||||
switch (this) {
|
||||
case TriggerSource.automatic:
|
||||
return AppTheme.accentCyan;
|
||||
case TriggerSource.manual:
|
||||
return Colors.purpleAccent;
|
||||
case TriggerSource.unknown:
|
||||
return AppTheme.textMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `FinlyticCore.Dtos.TechnicalAnalysis.UniverseSource` - which recurring
|
||||
/// FinlyticTechnicals selection mechanism added the ISIN to the continuously
|
||||
/// scanned universe before this evaluation ran. `null` on the Dart side (not
|
||||
/// modeled as its own enum value here) means the evaluation happened outside
|
||||
/// that universe entirely (e.g. a manual "Analyze now" call).
|
||||
enum UniverseSource {
|
||||
sentimentSpike,
|
||||
userFavorite,
|
||||
discovery;
|
||||
|
||||
static UniverseSource? fromJson(String? raw) {
|
||||
switch (raw) {
|
||||
case 'SentimentSpike':
|
||||
return UniverseSource.sentimentSpike;
|
||||
case 'UserFavorite':
|
||||
return UniverseSource.userFavorite;
|
||||
case 'Discovery':
|
||||
return UniverseSource.discovery;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case UniverseSource.sentimentSpike:
|
||||
return 'Sentiment-Spike';
|
||||
case UniverseSource.userFavorite:
|
||||
return 'Nutzer-Favorit';
|
||||
case UniverseSource.discovery:
|
||||
return 'Discovery-Liste';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_entry_model.dart';
|
||||
import 'evaluation_history_summary_model.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.GetEvaluationHistoryResponse` — the
|
||||
/// full response body of `GET /api/v1/admin/evaluations`.
|
||||
class EvaluationHistoryResponseModel extends Equatable {
|
||||
final int totalCount;
|
||||
final List<EvaluationHistoryEntryModel> entries;
|
||||
final EvaluationHistorySummaryModel summary;
|
||||
|
||||
const EvaluationHistoryResponseModel({
|
||||
required this.totalCount,
|
||||
required this.entries,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
factory EvaluationHistoryResponseModel.empty() => EvaluationHistoryResponseModel(
|
||||
totalCount: 0,
|
||||
entries: const [],
|
||||
summary: EvaluationHistorySummaryModel.empty(),
|
||||
);
|
||||
|
||||
factory EvaluationHistoryResponseModel.fromJson(Map<String, dynamic> json) {
|
||||
final rawEntries = json['entries'];
|
||||
final entries = rawEntries is List
|
||||
? rawEntries.whereType<Map<String, dynamic>>().map(EvaluationHistoryEntryModel.fromJson).toList()
|
||||
: <EvaluationHistoryEntryModel>[];
|
||||
|
||||
final rawSummary = json['summary'];
|
||||
final summary = rawSummary is Map<String, dynamic>
|
||||
? EvaluationHistorySummaryModel.fromJson(rawSummary)
|
||||
: EvaluationHistorySummaryModel.empty();
|
||||
|
||||
return EvaluationHistoryResponseModel(
|
||||
totalCount: json['totalCount'] is num ? (json['totalCount'] as num).toInt() : 0,
|
||||
entries: entries,
|
||||
summary: summary,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [totalCount, entries, summary];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_enums.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.OutcomeReasonCountDto`.
|
||||
class OutcomeReasonCountModel extends Equatable {
|
||||
final OutcomeReason outcomeReason;
|
||||
final int count;
|
||||
|
||||
const OutcomeReasonCountModel({required this.outcomeReason, required this.count});
|
||||
|
||||
factory OutcomeReasonCountModel.fromJson(Map<String, dynamic> json) {
|
||||
return OutcomeReasonCountModel(
|
||||
outcomeReason: OutcomeReason.fromJson(json['outcomeReason']?.toString()),
|
||||
count: json['count'] is num ? (json['count'] as num).toInt() : 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [outcomeReason, count];
|
||||
}
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.Trading.EvaluationHistorySummaryDto` — the
|
||||
/// pre-aggregated headline numbers for the admin evaluation-history tab. Every
|
||||
/// field except [lastProposalCreatedAtUtc] is scoped to the same filters as the
|
||||
/// paginated entry list it accompanies; [lastProposalCreatedAtUtc] deliberately
|
||||
/// ignores the from/to filters (see the server-side DTO doc comment) so the admin
|
||||
/// always sees "how long since the last real proposal" regardless of which
|
||||
/// historical window is currently selected.
|
||||
class EvaluationHistorySummaryModel extends Equatable {
|
||||
final int totalEvaluations;
|
||||
final List<OutcomeReasonCountModel> countsByOutcome;
|
||||
final double averageCompositeScore;
|
||||
final int proposalsCreated;
|
||||
final DateTime? lastProposalCreatedAtUtc;
|
||||
|
||||
const EvaluationHistorySummaryModel({
|
||||
required this.totalEvaluations,
|
||||
required this.countsByOutcome,
|
||||
required this.averageCompositeScore,
|
||||
required this.proposalsCreated,
|
||||
this.lastProposalCreatedAtUtc,
|
||||
});
|
||||
|
||||
int countFor(OutcomeReason reason) {
|
||||
for (final c in countsByOutcome) {
|
||||
if (c.outcomeReason == reason) return c.count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory EvaluationHistorySummaryModel.empty() => const EvaluationHistorySummaryModel(
|
||||
totalEvaluations: 0,
|
||||
countsByOutcome: [],
|
||||
averageCompositeScore: 0,
|
||||
proposalsCreated: 0,
|
||||
lastProposalCreatedAtUtc: null,
|
||||
);
|
||||
|
||||
factory EvaluationHistorySummaryModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
final rawCounts = json['countsByOutcome'];
|
||||
final counts = rawCounts is List
|
||||
? rawCounts.whereType<Map<String, dynamic>>().map(OutcomeReasonCountModel.fromJson).toList()
|
||||
: <OutcomeReasonCountModel>[];
|
||||
|
||||
final rawLast = json['lastProposalCreatedAtUtc'];
|
||||
|
||||
return EvaluationHistorySummaryModel(
|
||||
totalEvaluations: json['totalEvaluations'] is num ? (json['totalEvaluations'] as num).toInt() : 0,
|
||||
countsByOutcome: counts,
|
||||
averageCompositeScore: parseDbl(json['averageCompositeScore']),
|
||||
proposalsCreated: json['proposalsCreated'] is num ? (json['proposalsCreated'] as num).toInt() : 0,
|
||||
lastProposalCreatedAtUtc: rawLast != null ? DateTime.tryParse(rawLast.toString())?.toUtc() : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [totalEvaluations, countsByOutcome, averageCompositeScore, proposalsCreated, lastProposalCreatedAtUtc];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Typed mirror of the fields the admin UI needs from
|
||||
/// `FinlyticCore.Dtos.TechnicalAnalysis.StrategyResultDto`, as returned by
|
||||
/// `GET /api/v1/admin/evaluations/watchlist/{isin}/history` — the last N
|
||||
/// technical-analysis setups computed for one ISIN, most recent first, so the
|
||||
/// score trend (improving/worsening, and whether it ever cleared the engine's
|
||||
/// top-pick bar) is visible even for setups too weak to ever reach the engine.
|
||||
class RecentSetupModel extends Equatable {
|
||||
final String strategyName;
|
||||
final double qualityScore;
|
||||
final bool isTopPick;
|
||||
final String rating;
|
||||
final DateTime createdAt;
|
||||
|
||||
const RecentSetupModel({
|
||||
required this.strategyName,
|
||||
required this.qualityScore,
|
||||
required this.isTopPick,
|
||||
required this.rating,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory RecentSetupModel.fromJson(Map<String, dynamic> json) {
|
||||
double parseDbl(dynamic val) {
|
||||
if (val == null) return 0.0;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
return RecentSetupModel(
|
||||
strategyName: json['strategyName']?.toString() ?? '',
|
||||
qualityScore: parseDbl(json['qualityScore']),
|
||||
isTopPick: json['isTopPick'] == true,
|
||||
rating: json['rating']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [strategyName, qualityScore, isTopPick, rating, createdAt];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'evaluation_history_enums.dart';
|
||||
|
||||
/// Typed mirror of `FinlyticCore.Dtos.TechnicalAnalysis.WatchlistEntryDto` — one
|
||||
/// row of `GET /api/v1/admin/evaluations/watchlist`: an asset FinlyticTechnicals'
|
||||
/// background scanner is actually evaluating every cycle, independent of
|
||||
/// whether it has produced any evaluation the engine ever saw.
|
||||
class WatchlistEntryModel extends Equatable {
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
final UniverseSource? source;
|
||||
final int priority;
|
||||
final DateTime addedAtUtc;
|
||||
final DateTime? expiresAtUtc;
|
||||
|
||||
const WatchlistEntryModel({
|
||||
required this.isin,
|
||||
this.symbol,
|
||||
this.source,
|
||||
required this.priority,
|
||||
required this.addedAtUtc,
|
||||
this.expiresAtUtc,
|
||||
});
|
||||
|
||||
factory WatchlistEntryModel.fromJson(Map<String, dynamic> json) {
|
||||
DateTime parseDate(dynamic val) {
|
||||
return DateTime.tryParse(val?.toString() ?? '')?.toUtc() ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
return WatchlistEntryModel(
|
||||
isin: json['isin']?.toString() ?? '',
|
||||
symbol: json['symbol']?.toString(),
|
||||
source: UniverseSource.fromJson(json['source']?.toString()),
|
||||
priority: json['priority'] is num ? (json['priority'] as num).toInt() : 0,
|
||||
addedAtUtc: parseDate(json['addedAtUtc']),
|
||||
expiresAtUtc: json['expiresAtUtc'] == null ? null : parseDate(json['expiresAtUtc']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [isin, symbol, source, priority, addedAtUtc, expiresAtUtc];
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:finlytic_app/core/network/api_client.dart';
|
||||
import 'package:finlytic_app/features/admin/models/admin_user_model.dart';
|
||||
import 'package:finlytic_app/features/admin/models/admin_create_user_request_dto.dart';
|
||||
import 'package:finlytic_app/features/admin/models/admin_update_user_request_dto.dart';
|
||||
import 'package:finlytic_app/features/admin/models/evaluation_history_enums.dart';
|
||||
import 'package:finlytic_app/features/admin/models/evaluation_history_response_model.dart';
|
||||
import 'package:finlytic_app/features/admin/models/recent_setup_model.dart';
|
||||
import 'package:finlytic_app/features/admin/models/service_setting_dto.dart';
|
||||
import 'package:finlytic_app/features/admin/models/watchlist_entry_model.dart';
|
||||
|
||||
class AdminRepository {
|
||||
final ApiClient apiClient;
|
||||
@@ -60,4 +65,86 @@ class AdminRepository {
|
||||
throw Exception('Einstellungen konnten nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a filtered, paginated page of the evaluation history plus its
|
||||
/// accompanying summary from `GET /api/v1/admin/evaluations`
|
||||
/// (`AdminEvaluationHistoryController`). All filter parameters are optional —
|
||||
/// omitting one means "do not filter on this field", mirroring the server
|
||||
/// contract exactly (`GetEvaluationHistoryRequest`).
|
||||
///
|
||||
/// `[Authorize(Roles = "Admin")]` on the server means a non-admin caller (or an
|
||||
/// expired/invalid token) gets a `401`/`403`, which `ApiClient`'s interceptor
|
||||
/// already turns into an auto-logout (Rules.md §8) before this method's
|
||||
/// `catch` even runs — this method only has to turn the remaining
|
||||
/// error responses (engine unreachable `503`, RPC timeout `502`, unexpected
|
||||
/// `500` — all `ProblemDetails` bodies per the controller) into a readable
|
||||
/// message instead of letting a raw `DioException` reach the UI.
|
||||
Future<EvaluationHistoryResponseModel> fetchEvaluationHistory({
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
OutcomeReason? outcome,
|
||||
TriggerSource? triggerSource,
|
||||
String? search,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
final query = <String, dynamic>{
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
};
|
||||
if (fromUtc != null) query['fromUtc'] = fromUtc.toUtc().toIso8601String();
|
||||
if (toUtc != null) query['toUtc'] = toUtc.toUtc().toIso8601String();
|
||||
if (outcome != null) query['outcome'] = outcome.toApiValue();
|
||||
if (triggerSource != null) query['triggerSource'] = triggerSource.toApiValue();
|
||||
if (search != null && search.trim().isNotEmpty) query['search'] = search.trim();
|
||||
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/admin/evaluations', queryParameters: query);
|
||||
if (res.data is Map<String, dynamic>) {
|
||||
return EvaluationHistoryResponseModel.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
return EvaluationHistoryResponseModel.empty();
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||
throw Exception(title ?? 'Evaluierungs-Historie konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches FinlyticTechnicals' current scan universe ("watchlist") from
|
||||
/// `GET /api/v1/admin/evaluations/watchlist` — the assets actually being
|
||||
/// evaluated every cycle in the background, independent of the (filtered)
|
||||
/// evaluation history above.
|
||||
Future<List<WatchlistEntryModel>> fetchWatchlist() async {
|
||||
try {
|
||||
final res = await apiClient.get('/api/v1/admin/evaluations/watchlist');
|
||||
if (res.data is List) {
|
||||
return (res.data as List).whereType<Map<String, dynamic>>().map(WatchlistEntryModel.fromJson).toList();
|
||||
}
|
||||
return [];
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||
throw Exception(title ?? 'Watchlist konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the last [limit] technical-analysis setups computed for [isin]
|
||||
/// (most recent first) from `GET /api/v1/admin/evaluations/watchlist/{isin}/history`.
|
||||
Future<List<RecentSetupModel>> fetchWatchlistEntryHistory(String isin, {int limit = 8}) async {
|
||||
try {
|
||||
final res = await apiClient.get(
|
||||
'/api/v1/admin/evaluations/watchlist/${Uri.encodeComponent(isin)}/history',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
if (res.data is List) {
|
||||
return (res.data as List).whereType<Map<String, dynamic>>().map(RecentSetupModel.fromJson).toList();
|
||||
}
|
||||
return [];
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
final title = (data is Map && data['title'] is String) ? data['title'] as String : null;
|
||||
throw Exception(title ?? 'Score-Verlauf konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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/glass_container.dart';
|
||||
import '../../../shared/widgets/evaluation_score_breakdown_sheet.dart';
|
||||
import '../bloc/admin_evaluation_history_bloc.dart';
|
||||
import '../models/evaluation_history_entry_model.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
import '../models/evaluation_history_summary_model.dart';
|
||||
import '../repositories/admin_repository.dart';
|
||||
import '../widgets/evaluation_history_filter_bar.dart';
|
||||
import '../widgets/evaluation_history_kpi_header.dart';
|
||||
import '../widgets/evaluation_history_list_item.dart';
|
||||
|
||||
/// Admin-only tab showing the full history of every asset evaluation the
|
||||
/// engine ever ran — approved or not, automatic or manual — so an admin can
|
||||
/// see directly *why* no new trade proposal appeared instead of having to
|
||||
/// query the database by hand. Backed by `GET /api/v1/admin/evaluations`
|
||||
/// (`AdminEvaluationHistoryController`, `[Authorize(Roles = "Admin")]`).
|
||||
///
|
||||
/// This screen is only ever mounted from `ResponsiveScaffold` behind an
|
||||
/// `if (widget.user.isAdmin)` guard, same as the Bot Panel/Backtest/Admin
|
||||
/// Panel tabs — that guard is UX only, not a security boundary. The real
|
||||
/// boundary is the server-side `[Authorize(Roles = "Admin")]`: if a non-admin
|
||||
/// (or an expired-token admin) somehow still reaches this screen, the 401/403
|
||||
/// response is caught by `ApiClient`'s central interceptor, which clears the
|
||||
/// stored token and triggers auto-logout (Rules.md §8) — the bloc below just
|
||||
/// has to not crash on the `AdminEvaluationHistoryError` that results in the
|
||||
/// meantime, which it doesn't (it renders a normal retryable error state).
|
||||
class AdminEvaluationHistoryScreen extends StatelessWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const AdminEvaluationHistoryScreen({super.key, required this.apiClient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => AdminEvaluationHistoryBloc(
|
||||
repository: AdminRepository(apiClient: apiClient),
|
||||
)..add(const FetchEvaluationHistory()),
|
||||
child: _AdminEvaluationHistoryScreenContent(apiClient: apiClient),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminEvaluationHistoryScreenContent extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
|
||||
const _AdminEvaluationHistoryScreenContent({required this.apiClient});
|
||||
|
||||
@override
|
||||
State<_AdminEvaluationHistoryScreenContent> createState() => _AdminEvaluationHistoryScreenContentState();
|
||||
}
|
||||
|
||||
class _AdminEvaluationHistoryScreenContentState extends State<_AdminEvaluationHistoryScreenContent> {
|
||||
static const int _pageSize = 50;
|
||||
|
||||
DateTime? _fromUtc;
|
||||
DateTime? _toUtc;
|
||||
OutcomeReason? _outcome;
|
||||
TriggerSource? _triggerSource;
|
||||
String _search = '';
|
||||
int _page = 1;
|
||||
|
||||
void _fetch() {
|
||||
context.read<AdminEvaluationHistoryBloc>().add(FetchEvaluationHistory(
|
||||
fromUtc: _fromUtc,
|
||||
toUtc: _toUtc,
|
||||
outcome: _outcome,
|
||||
triggerSource: _triggerSource,
|
||||
search: _search,
|
||||
page: _page,
|
||||
pageSize: _pageSize,
|
||||
));
|
||||
}
|
||||
|
||||
void _onFilterChanged({
|
||||
required DateTime? fromUtc,
|
||||
required DateTime? toUtc,
|
||||
required OutcomeReason? outcome,
|
||||
required TriggerSource? triggerSource,
|
||||
required String search,
|
||||
}) {
|
||||
setState(() {
|
||||
_fromUtc = fromUtc;
|
||||
_toUtc = toUtc;
|
||||
_outcome = outcome;
|
||||
_triggerSource = triggerSource;
|
||||
_search = search;
|
||||
_page = 1;
|
||||
});
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _goToPage(int page) {
|
||||
setState(() => _page = page);
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _showDetail(EvaluationHistoryEntryModel entry) {
|
||||
final approvedLike = entry.outcomeReason == OutcomeReason.approved || entry.passedAiValidation;
|
||||
|
||||
EvaluationScoreBreakdownSheet.show(
|
||||
context,
|
||||
title: '${entry.symbol.isNotEmpty ? entry.symbol : entry.isin} · ${entry.outcomeReason.label}',
|
||||
subtitle: 'Evaluiert am ${_formatFullTimestamp(entry.evaluatedAtUtc)} · Ausgelöst: ${entry.triggerSource.label}.',
|
||||
headerIcon: approvedLike ? Icons.psychology_outlined : Icons.block_outlined,
|
||||
headerColor: approvedLike ? AppTheme.primaryEmerald : entry.outcomeReason.color,
|
||||
compositeScore: entry.compositeOpportunityScore,
|
||||
technicalScore: entry.technicalScore,
|
||||
sentimentScore: entry.sentimentScore,
|
||||
fundamentalScore: entry.fundamentalScore,
|
||||
reliabilityBonus: entry.reliabilityBonus,
|
||||
passedEarningsLockout: entry.passedEarningsLockout,
|
||||
daysToNextEarnings: entry.daysToNextEarnings,
|
||||
passedDividendGate: entry.passedDividendGate,
|
||||
daysToNextExDividend: entry.daysToNextExDividend,
|
||||
universeSourceLabel: entry.universeSource?.label,
|
||||
universeEnteredAtUtc: entry.universeEnteredAtUtc,
|
||||
passedSimulationVeto: entry.passedSimulationVeto,
|
||||
reasoningLabel: entry.passedAiValidation ? 'KI-These' : 'Ablehnungsgrund',
|
||||
reasoningText: entry.aiThesisSummary,
|
||||
footer: entry.hasProposal
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryEmerald.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.rocket_launch_outlined, size: 16, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Aus dieser Analyse entstand ein Trade-Vorschlag (Proposal-ID: ${entry.proposalId}).',
|
||||
style: TextStyle(color: AppTheme.primaryEmerald, fontSize: 12, height: 1.4, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFullTimestamp(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) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Evaluierungs-Historie',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Jede Analyse, jeder Filter, jedes Ergebnis – nachvollziehbar ohne DB-Zugriff.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _fetch,
|
||||
icon: const Icon(Icons.refresh_rounded, color: Colors.white70),
|
||||
tooltip: 'Neu laden',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: BlocBuilder<AdminEvaluationHistoryBloc, AdminEvaluationHistoryState>(
|
||||
builder: (context, state) {
|
||||
final summary = state is AdminEvaluationHistoryLoaded ? state.response.summary : EvaluationHistorySummaryModel.empty();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
EvaluationHistoryKpiHeader(summary: summary, apiClient: widget.apiClient),
|
||||
const SizedBox(height: 16),
|
||||
EvaluationHistoryFilterBar(
|
||||
fromUtc: _fromUtc,
|
||||
toUtc: _toUtc,
|
||||
outcome: _outcome,
|
||||
triggerSource: _triggerSource,
|
||||
search: _search,
|
||||
onChanged: _onFilterChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildBody(state),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(AdminEvaluationHistoryState state) {
|
||||
if (state is AdminEvaluationHistoryLoading || state is AdminEvaluationHistoryInitial) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald)),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AdminEvaluationHistoryError) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, color: AppTheme.accentRed, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _fetch,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final loaded = state as AdminEvaluationHistoryLoaded;
|
||||
final entries = loaded.response.entries;
|
||||
|
||||
if (entries.isEmpty) {
|
||||
// Explicit empty state (Rules.md §4) — never a silent blank list, so an
|
||||
// admin who set a narrow filter knows the filter matched nothing rather
|
||||
// than wondering whether the tab itself is broken.
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 44, color: AppTheme.textMuted),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Keine Analysen im gewählten Zeitraum/Filter gefunden.',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Server already returns each page sorted by EvaluatedAtUtc descending
|
||||
// (EvaluationHistoryService.GetHistoryAsync: .OrderByDescending(s =>
|
||||
// s.EvaluatedAtUtc)) — rendered in received order, no client re-sort needed.
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return EvaluationHistoryListItem(entry: entry, onTap: () => _showDetail(entry));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
loaded.response.totalCount == 0
|
||||
? '0 Einträge'
|
||||
: '${loaded.rangeStart}–${loaded.rangeEnd} von ${loaded.response.totalCount}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: loaded.hasPreviousPage ? () => _goToPage(_page - 1) : null,
|
||||
child: const Text('Zurück'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: loaded.hasNextPage ? () => _goToPage(_page + 1) : null,
|
||||
child: const Text('Weiter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
}
|
||||
|
||||
String _formatLabel(String key) {
|
||||
return key
|
||||
// Strip the "Logging.Channel." prefix for display - the section header already says "Logging-Kanäle",
|
||||
// repeating it on every single chip label added visual noise without any extra information.
|
||||
final withoutChannelPrefix = key.startsWith('Logging.Channel.') ? key.substring('Logging.Channel.'.length) : key;
|
||||
|
||||
return withoutChannelPrefix
|
||||
.replaceAll(RegExp(r'(?<!^)(?=[A-Z])'), ' ')
|
||||
.replaceAll('Minutes', '(Minuten)')
|
||||
.replaceAll('Seconds', '(Sekunden)')
|
||||
@@ -143,6 +147,187 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
.replaceAll('Multiplier', 'Multiplikator');
|
||||
}
|
||||
|
||||
/// Groups settings by kind so the settings card reads as organized sections instead of one long,
|
||||
/// unstructured list mixing logging toggles, feature switches, numeric thresholds, and free text together.
|
||||
/// Order is fixed (not alphabetical) so the most-scanned category (logging channels, usually the most
|
||||
/// numerous) sits first.
|
||||
static const List<String> _categoryOrder = ['Logging-Kanäle', 'Umschalter', 'Zahlenwerte', 'Text'];
|
||||
|
||||
String _categoryFor(ServiceSettingDto s) {
|
||||
if (s.key.startsWith('Logging.Channel.')) return 'Logging-Kanäle';
|
||||
|
||||
final type = s.type.toLowerCase();
|
||||
final looksBoolean = type == 'bool' || s.value.toLowerCase() == 'true' || s.value.toLowerCase() == 'false';
|
||||
if (looksBoolean) return 'Umschalter';
|
||||
|
||||
final looksNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||
if (looksNumeric) return 'Zahlenwerte';
|
||||
|
||||
return 'Text';
|
||||
}
|
||||
|
||||
Map<String, List<ServiceSettingDto>> get _groupedSettings {
|
||||
final groups = <String, List<ServiceSettingDto>>{};
|
||||
for (final s in _settings) {
|
||||
groups.putIfAbsent(_categoryFor(s), () => []).add(s);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 15, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textMuted, letterSpacing: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact toggle "chip" for a single boolean setting - used for logging channels, which can easily number
|
||||
/// a dozen+ per service, so a full-width `SwitchListTile` per entry (the previous, only, layout for every
|
||||
/// setting regardless of category or count) made the card feel "gequetscht"/cramped and pushed the actually
|
||||
/// important numeric settings far down the page.
|
||||
Widget _buildToggleChip(ServiceSettingDto s, TextEditingController controller) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
|
||||
return Tooltip(
|
||||
message: s.description.isNotEmpty ? s.description : _formatLabel(s.key),
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => setState(() => controller.text = (!boolVal).toString()),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.5) : AppTheme.glassBorder),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
boolVal ? Icons.check_circle : Icons.circle_outlined,
|
||||
size: 14,
|
||||
color: boolVal ? AppTheme.primaryEmerald : AppTheme.textMuted,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatLabel(s.key),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: boolVal ? Colors.white : AppTheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchSetting(ServiceSettingDto s, TextEditingController controller) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: boolVal ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : AppTheme.glassBorder),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_formatLabel(s.key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
subtitle: s.description.isNotEmpty ? Text(s.description, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||
value: boolVal,
|
||||
activeThumbColor: AppTheme.primaryEmerald,
|
||||
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFieldSetting(ServiceSettingDto s, TextEditingController controller, {required bool isNumeric}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: _formatLabel(s.key),
|
||||
helperText: s.description.isNotEmpty ? s.description : null,
|
||||
helperMaxLines: 2,
|
||||
prefixIcon: Icon(
|
||||
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryEmerald,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildGroupedSettingsSections() {
|
||||
final grouped = _groupedSettings;
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (final category in _categoryOrder) {
|
||||
final items = grouped[category];
|
||||
if (items == null || items.isEmpty) continue;
|
||||
|
||||
widgets.add(_buildSectionHeader(
|
||||
'$category (${items.length})',
|
||||
switch (category) {
|
||||
'Logging-Kanäle' => Icons.terminal_rounded,
|
||||
'Umschalter' => Icons.toggle_on_outlined,
|
||||
'Zahlenwerte' => Icons.numbers_outlined,
|
||||
_ => Icons.tune_outlined,
|
||||
},
|
||||
));
|
||||
|
||||
if (category == 'Logging-Kanäle') {
|
||||
final chips = <Widget>[];
|
||||
for (final s in items) {
|
||||
final controller = _controllers[s.key];
|
||||
if (controller != null) chips.add(_buildToggleChip(s, controller));
|
||||
}
|
||||
widgets.add(Wrap(spacing: 8, runSpacing: 8, children: chips));
|
||||
} else if (category == 'Umschalter') {
|
||||
for (final s in items) {
|
||||
final controller = _controllers[s.key];
|
||||
if (controller != null) widgets.add(_buildSwitchSetting(s, controller));
|
||||
}
|
||||
} else {
|
||||
for (final s in items) {
|
||||
final controller = _controllers[s.key];
|
||||
if (controller != null) {
|
||||
widgets.add(_buildTextFieldSetting(s, controller, isNumeric: category == 'Zahlenwerte'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widgets.add(const SizedBox(height: 14));
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -179,66 +364,7 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
if (_settings.isEmpty)
|
||||
const Text('Keine spezifischen Einstellungen gefunden.')
|
||||
else
|
||||
..._settings.map((s) {
|
||||
final key = s.key;
|
||||
final desc = s.description;
|
||||
final type = s.type.toLowerCase();
|
||||
final controller = _controllers[key];
|
||||
if (controller == null) return const SizedBox.shrink();
|
||||
|
||||
final isBoolean = type == 'bool' ||
|
||||
controller.text.toLowerCase() == 'true' ||
|
||||
controller.text.toLowerCase() == 'false';
|
||||
|
||||
if (isBoolean) {
|
||||
final boolVal = controller.text.toLowerCase() == 'true';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: boolVal
|
||||
? AppTheme.primaryEmerald.withValues(alpha: 0.4)
|
||||
: AppTheme.glassBorder,
|
||||
),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_formatLabel(key), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||
value: boolVal,
|
||||
activeThumbColor: AppTheme.primaryEmerald,
|
||||
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: isNumeric
|
||||
? const TextInputType.numberWithOptions(decimal: true)
|
||||
: TextInputType.text,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: _formatLabel(key),
|
||||
helperText: desc.isNotEmpty ? desc : null,
|
||||
helperMaxLines: 2,
|
||||
prefixIcon: Icon(
|
||||
isNumeric ? Icons.numbers_outlined : Icons.tune_outlined,
|
||||
size: 18,
|
||||
color: AppTheme.primaryEmerald,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
..._buildGroupedSettingsSections(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
if (_settings.isNotEmpty)
|
||||
@@ -269,18 +395,6 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
||||
serviceName: widget.serviceName,
|
||||
apiClient: widget.apiClient,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Statistiken', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Live-Statistiken werden noch implementiert...', style: TextStyle(fontStyle: FontStyle.italic, color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
|
||||
/// Full filter snapshot emitted by [EvaluationHistoryFilterBar.onChanged] on
|
||||
/// every single control change — deliberately not a partial/sparse update, so
|
||||
/// there is no ambiguity between "caller didn't touch this field" and "caller
|
||||
/// explicitly cleared this field" on the receiving end.
|
||||
typedef EvaluationHistoryFilterChanged = void Function({
|
||||
required DateTime? fromUtc,
|
||||
required DateTime? toUtc,
|
||||
required OutcomeReason? outcome,
|
||||
required TriggerSource? triggerSource,
|
||||
required String search,
|
||||
});
|
||||
|
||||
/// Filter bar for the admin evaluation-history tab: a from/to date range (plain
|
||||
/// `showDatePicker` — a full calendar-range widget is overkill for "roughly which
|
||||
/// days"), an [OutcomeReason] dropdown, a [TriggerSource] dropdown, and an
|
||||
/// ISIN/symbol search field. All four map 1:1 onto the server's optional query
|
||||
/// filters (`fromUtc`/`toUtc`/`outcome`/`triggerSource`/`search`).
|
||||
class EvaluationHistoryFilterBar extends StatefulWidget {
|
||||
final DateTime? fromUtc;
|
||||
final DateTime? toUtc;
|
||||
final OutcomeReason? outcome;
|
||||
final TriggerSource? triggerSource;
|
||||
final String search;
|
||||
final EvaluationHistoryFilterChanged onChanged;
|
||||
|
||||
const EvaluationHistoryFilterBar({
|
||||
super.key,
|
||||
required this.fromUtc,
|
||||
required this.toUtc,
|
||||
required this.outcome,
|
||||
required this.triggerSource,
|
||||
required this.search,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EvaluationHistoryFilterBar> createState() => _EvaluationHistoryFilterBarState();
|
||||
}
|
||||
|
||||
class _EvaluationHistoryFilterBarState extends State<EvaluationHistoryFilterBar> {
|
||||
late final TextEditingController _searchController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController(text: widget.search);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isFrom}) async {
|
||||
final initial = (isFrom ? widget.fromUtc : widget.toUtc)?.toLocal() ?? DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: DateTime(2020, 1, 1),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
if (isFrom) {
|
||||
_emit(fromUtc: DateTime.utc(picked.year, picked.month, picked.day));
|
||||
} else {
|
||||
// Inclusive upper bound on the whole selected day.
|
||||
_emit(toUtc: DateTime.utc(picked.year, picked.month, picked.day, 23, 59, 59));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the full filter snapshot, overriding only the field(s) that
|
||||
/// actually changed and carrying every other field through unchanged from
|
||||
/// `widget.*` — see [EvaluationHistoryFilterChanged].
|
||||
void _emit({
|
||||
Object? fromUtc = _unset,
|
||||
Object? toUtc = _unset,
|
||||
Object? outcome = _unset,
|
||||
Object? triggerSource = _unset,
|
||||
String? search,
|
||||
}) {
|
||||
widget.onChanged(
|
||||
fromUtc: fromUtc == _unset ? widget.fromUtc : fromUtc as DateTime?,
|
||||
toUtc: toUtc == _unset ? widget.toUtc : toUtc as DateTime?,
|
||||
outcome: outcome == _unset ? widget.outcome : outcome as OutcomeReason?,
|
||||
triggerSource: triggerSource == _unset ? widget.triggerSource : triggerSource as TriggerSource?,
|
||||
search: search ?? widget.search,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? dt) {
|
||||
if (dt == null) return 'Egal';
|
||||
final local = dt.toLocal();
|
||||
return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onSubmitted: (val) => _emit(search: val),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ISIN oder Symbol suchen...',
|
||||
prefixIcon: Icon(Icons.search_rounded, color: AppTheme.textMuted),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_emit(search: '');
|
||||
},
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||
onPressed: () => _emit(search: _searchController.text),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_dateChip(label: 'Von: ${_formatDate(widget.fromUtc)}', onTap: () => _pickDate(isFrom: true)),
|
||||
_dateChip(label: 'Bis: ${_formatDate(widget.toUtc)}', onTap: () => _pickDate(isFrom: false)),
|
||||
if (widget.fromUtc != null || widget.toUtc != null)
|
||||
TextButton(
|
||||
onPressed: () => _emit(fromUtc: null, toUtc: null),
|
||||
child: const Text('Zeitraum zurücksetzen', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 190,
|
||||
child: DropdownButtonFormField<OutcomeReason?>(
|
||||
initialValue: widget.outcome,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Ergebnis', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem<OutcomeReason?>(value: null, child: Text('Alle Ergebnisse')),
|
||||
...OutcomeReason.values.map(
|
||||
(r) => DropdownMenuItem<OutcomeReason?>(value: r, child: Text(r.label)),
|
||||
),
|
||||
],
|
||||
onChanged: (val) => _emit(outcome: val),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 170,
|
||||
child: DropdownButtonFormField<TriggerSource?>(
|
||||
initialValue: widget.triggerSource,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Ausgelöst durch', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem<TriggerSource?>(value: null, child: Text('Alle Quellen')),
|
||||
...TriggerSource.values.map(
|
||||
(t) => DropdownMenuItem<TriggerSource?>(value: t, child: Text(t.label)),
|
||||
),
|
||||
],
|
||||
onChanged: (val) => _emit(triggerSource: val),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateChip({required String label, required VoidCallback onTap}) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onTap,
|
||||
icon: Icon(Icons.calendar_today_outlined, size: 14, color: AppTheme.textSecondary),
|
||||
label: Text(label, style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel distinguishing "caller of [_EvaluationHistoryFilterBarState._emit]
|
||||
/// didn't touch this field" (default) from "caller explicitly passed `null`"
|
||||
/// (clear this field) — needed because `Object?`'s own null is one of the two
|
||||
/// values this default has to be distinguishable from.
|
||||
const Object _unset = Object();
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/time_utils.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../models/evaluation_history_enums.dart';
|
||||
import '../models/evaluation_history_summary_model.dart';
|
||||
import 'watchlist_card.dart';
|
||||
|
||||
/// Headline KPI row for the admin evaluation-history tab: how many analyses ran
|
||||
/// in the current filter window, the outcome breakdown (this is what makes a
|
||||
/// "why are there no new proposals" question answerable at a glance — e.g. most
|
||||
/// assets sitting in [OutcomeReason.belowScoreThreshold]), the average composite
|
||||
/// score, and how long ago the last trade proposal was actually created.
|
||||
///
|
||||
/// Every number here comes straight from `EvaluationHistorySummaryModel`
|
||||
/// (server-aggregated over the same filtered set as the entry list) — nothing is
|
||||
/// computed client-side from the current page alone (Rules.md §4).
|
||||
class EvaluationHistoryKpiHeader extends StatelessWidget {
|
||||
final EvaluationHistorySummaryModel summary;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const EvaluationHistoryKpiHeader({super.key, required this.summary, required this.apiClient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||
|
||||
final lastProposalText = summary.lastProposalCreatedAtUtc == null
|
||||
? 'Noch nie'
|
||||
: TimeUtils.formatRelativeTime(summary.lastProposalCreatedAtUtc!.toIso8601String());
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
isMobile
|
||||
? Column(
|
||||
children: [
|
||||
_kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan),
|
||||
const SizedBox(height: 10),
|
||||
_kpiCard('Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 10),
|
||||
_kpiCard(
|
||||
'Letzter Vorschlag',
|
||||
lastProposalText,
|
||||
Icons.rocket_launch_outlined,
|
||||
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: _kpiCard('Analysen (Filter)', '${summary.totalEvaluations}', Icons.query_stats_rounded, AppTheme.accentCyan)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _kpiCard(
|
||||
'Ø Composite-Score', summary.averageCompositeScore.toStringAsFixed(1), Icons.speed_rounded, AppTheme.primaryEmerald),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _kpiCard(
|
||||
'Letzter Vorschlag',
|
||||
lastProposalText,
|
||||
Icons.rocket_launch_outlined,
|
||||
summary.lastProposalCreatedAtUtc == null ? AppTheme.textMuted : Colors.amber,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
isMobile
|
||||
? Column(
|
||||
children: [
|
||||
_buildBreakdownCard(),
|
||||
const SizedBox(height: 10),
|
||||
WatchlistCard(apiClient: apiClient),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 2, child: _buildBreakdownCard()),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: WatchlistCard(apiClient: apiClient)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBreakdownCard() {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('AUFSCHLÜSSELUNG NACH GRUND', style: TextStyle(color: AppTheme.textSecondary, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 10),
|
||||
summary.totalEvaluations == 0
|
||||
? Text('Keine Analysen im gewählten Filter.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12))
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: OutcomeReason.values
|
||||
.map((reason) => _outcomeChip(reason, summary.countFor(reason)))
|
||||
.where((w) => w != null)
|
||||
.cast<Widget>()
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _outcomeChip(OutcomeReason reason, int count) {
|
||||
if (count == 0) return null;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: reason.color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: reason.color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
'${reason.label}: $count',
|
||||
style: TextStyle(color: reason.color, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kpiCard(String title, String value, IconData icon, Color color) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 11, color: AppTheme.textMuted, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textPrimary, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/widgets/status_badge.dart';
|
||||
import '../models/evaluation_history_entry_model.dart';
|
||||
|
||||
/// One row of the paginated evaluation-history list: symbol/ISIN, timestamp,
|
||||
/// composite score, and color-coded [OutcomeReason]/[TriggerSource] badges.
|
||||
/// Tapping opens the full score/reasoning breakdown via the caller-supplied
|
||||
/// [onTap] (wired to the shared `EvaluationScoreBreakdownSheet` by the screen).
|
||||
class EvaluationHistoryListItem extends StatelessWidget {
|
||||
final EvaluationHistoryEntryModel entry;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const EvaluationHistoryListItem({super.key, required this.entry, required this.onTap});
|
||||
|
||||
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 scoreColor = entry.compositeOpportunityScore >= 70
|
||||
? AppTheme.primaryEmerald
|
||||
: (entry.compositeOpportunityScore >= 40 ? Colors.amber : AppTheme.accentRed);
|
||||
|
||||
return GlassContainer(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: Text(
|
||||
entry.compositeOpportunityScore.toStringAsFixed(0),
|
||||
style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
entry.symbol.isNotEmpty ? entry.symbol : entry.isin,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
if (entry.symbol.isNotEmpty && entry.isin.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(entry.isin, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
if (entry.hasProposal) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.link_rounded, size: 13, color: AppTheme.primaryEmerald),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(_formatTimestamp(entry.evaluatedAtUtc), style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusBadge(label: entry.outcomeReason.label, color: entry.outcomeReason.color),
|
||||
const SizedBox(height: 6),
|
||||
StatusBadge(label: entry.triggerSource.label, color: entry.triggerSource.color),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right_rounded, color: AppTheme.textMuted, size: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,33 @@ class _LiveLogConsoleState extends State<LiveLogConsole> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses the backend's full `Microsoft.Extensions.Logging.LogLevel` names ("Information", "Warning",
|
||||
/// "Trace", "Critical", ...) down to the 4 short codes the filter chips use ("INFO", "WARN", "DEBUG",
|
||||
/// "ERROR"). The filter previously compared `log.level.toUpperCase()` ("INFORMATION") directly against the
|
||||
/// chip value ("INFO") - which never matched anything but "ALL", so selecting any specific level silently
|
||||
/// hid every log line instead of actually filtering.
|
||||
String _normalizeLevel(String level) {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'INFORMATION':
|
||||
case 'INFO':
|
||||
return 'INFO';
|
||||
case 'WARNING':
|
||||
case 'WARN':
|
||||
return 'WARN';
|
||||
case 'ERROR':
|
||||
case 'CRITICAL':
|
||||
return 'ERROR';
|
||||
case 'DEBUG':
|
||||
case 'TRACE':
|
||||
return 'DEBUG';
|
||||
default:
|
||||
return level.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
List<LogMessageDto> get _filteredLogs {
|
||||
return _logs.where((log) {
|
||||
if (_selectedLevel != 'ALL' && log.level.toUpperCase() != _selectedLevel) {
|
||||
if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _selectedLevel) {
|
||||
return false;
|
||||
}
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
|
||||
@@ -67,16 +67,9 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||
accentColor: Color(0xFFEC4899),
|
||||
),
|
||||
ServiceConfigMeta(
|
||||
key: 'FinlyticAnalyzer',
|
||||
displayName: 'Analyzer Signal Engine',
|
||||
description: 'Scraper Cron-Schedule & Minimaler Signal-Score',
|
||||
icon: Icons.analytics_outlined,
|
||||
accentColor: Color(0xFFF59E0B),
|
||||
),
|
||||
ServiceConfigMeta(
|
||||
key: 'FinlyticTrades',
|
||||
displayName: 'Trade Manager',
|
||||
description: 'ATR Stop-Loss Multiplikator, Risiko-Prozente & Positionen',
|
||||
key: 'FinlyticEngine',
|
||||
displayName: 'Trading Engine',
|
||||
description: 'Strategy Screener, Trade Lifecycle & Risikomanagement',
|
||||
icon: Icons.candlestick_chart_outlined,
|
||||
accentColor: Color(0xFF10B981),
|
||||
),
|
||||
@@ -87,6 +80,13 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||
icon: Icons.corporate_fare_outlined,
|
||||
accentColor: Color(0xFF06B6D4),
|
||||
),
|
||||
ServiceConfigMeta(
|
||||
key: 'FinlyticBot',
|
||||
displayName: 'FinlyticBot (Paper)',
|
||||
description: 'Alpaca Paper Trading, Risikomanagement & Sizing Engine',
|
||||
icon: Icons.smart_toy_outlined,
|
||||
accentColor: Color(0xFF10B981),
|
||||
),
|
||||
];
|
||||
|
||||
final Map<String, Map<String, TextEditingController>> _controllers = {
|
||||
@@ -113,24 +113,35 @@ class _PipelineSettingsWidgetState extends State<PipelineSettingsWidget> {
|
||||
'MinConfidenceScore': TextEditingController(text: '0.70'),
|
||||
'MaxBatchSize': TextEditingController(text: '50'),
|
||||
},
|
||||
'FinlyticAnalyzer': {
|
||||
'ScanCronSchedule': TextEditingController(text: '0 */1 * * *'),
|
||||
'MinSignalScore': TextEditingController(text: '75'),
|
||||
'EnableLog_MqttHealthPing': TextEditingController(text: 'false'),
|
||||
'EnableLog_MqttGeneral': TextEditingController(text: 'true'),
|
||||
'EnableLog_AnalyzerAuto': TextEditingController(text: 'true'),
|
||||
'EnableLog_AnalyzerManual': TextEditingController(text: 'true'),
|
||||
'EnableLog_DatabaseOps': TextEditingController(text: 'true'),
|
||||
},
|
||||
'FinlyticTrades': {
|
||||
'AtrStopLossMultiplier': TextEditingController(text: '1.5'),
|
||||
'RiskPerTradePercentage': TextEditingController(text: '1.0'),
|
||||
'MaxOpenPositions': TextEditingController(text: '5'),
|
||||
'FinlyticEngine': {
|
||||
'Engine.MinCompositeScore': TextEditingController(text: '75.0'),
|
||||
'Engine.WeightTechnical': TextEditingController(text: '0.45'),
|
||||
'Engine.WeightSentiment': TextEditingController(text: '0.35'),
|
||||
'Engine.WeightFundamental': TextEditingController(text: '0.20'),
|
||||
'Engine.EarningsLockoutDays': TextEditingController(text: '2'),
|
||||
'Engine.MinDerivativeLeverage': TextEditingController(text: '5.0'),
|
||||
'Engine.TargetDefaultLeverage': TextEditingController(text: '7.0'),
|
||||
'Engine.KnockOutSafetyBufferPercent': TextEditingController(text: '2.0'),
|
||||
'Engine.EnableAiValidation': TextEditingController(text: 'true'),
|
||||
'Engine.EnablePaperTradingBot': TextEditingController(text: 'false'),
|
||||
'Engine.PollingIntervalSeconds': TextEditingController(text: '120'),
|
||||
'Engine.MonitoringIntervalSeconds': TextEditingController(text: '60'),
|
||||
},
|
||||
'FinlyticFundamentals': {
|
||||
'CacheTtlHours': TextEditingController(text: '24'),
|
||||
'EnableYahooFallback': TextEditingController(text: 'true'),
|
||||
},
|
||||
'FinlyticBot': {
|
||||
'Alpaca.KeyId': TextEditingController(text: ''),
|
||||
'Alpaca.SecretKey': TextEditingController(text: ''),
|
||||
'Alpaca.IsPaper': TextEditingController(text: 'true'),
|
||||
'Bot.EnableAutoExecution': TextEditingController(text: 'true'),
|
||||
'Bot.RiskPerTradePercent': TextEditingController(text: '1.0'),
|
||||
'Bot.MaxPositionAllocationPercent': TextEditingController(text: '20.0'),
|
||||
'Bot.MaxConcurrentPositions': TextEditingController(text: '5'),
|
||||
'Bot.DailyLossLimitPercent': TextEditingController(text: '3.0'),
|
||||
'Bot.MonitoringIntervalSeconds': TextEditingController(text: '15'),
|
||||
},
|
||||
};
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
@@ -46,6 +46,32 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
IconData _getServiceIcon(String name) {
|
||||
switch (name) {
|
||||
case 'FinlyticBackend':
|
||||
return Icons.hub_outlined;
|
||||
case 'FinlyticAssets':
|
||||
return Icons.inventory_2_outlined;
|
||||
case 'FinlyticNews':
|
||||
return Icons.newspaper_outlined;
|
||||
case 'FinlyticTechnicals':
|
||||
case 'FinlyticTechnicalAnalysis':
|
||||
return Icons.show_chart_outlined;
|
||||
case 'FinlyticSentiment':
|
||||
return Icons.psychology_outlined;
|
||||
case 'FinlyticEngine':
|
||||
case 'FinlyticAnalyzer':
|
||||
case 'FinlyticTrades':
|
||||
return Icons.candlestick_chart_outlined;
|
||||
case 'FinlyticFundamentals':
|
||||
return Icons.corporate_fare_outlined;
|
||||
case 'FinlyticBot':
|
||||
return Icons.smart_toy_outlined;
|
||||
default:
|
||||
return Icons.dns_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int totalCount = _serviceStatuses.length;
|
||||
@@ -191,7 +217,7 @@ class _SystemDiagnosticsWidgetState extends State<SystemDiagnosticsWidget> {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
name == 'FinlyticBackend' ? Icons.hub_outlined : Icons.dns_outlined,
|
||||
_getServiceIcon(name),
|
||||
size: 18,
|
||||
color: isOnline ? AppTheme.primaryEmerald : AppTheme.accentRed,
|
||||
),
|
||||
|
||||
@@ -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