feat(app): clean architecture with typed repositories, DTO models and calendar event logos
This commit is contained in:
@@ -39,27 +39,29 @@ class NewsArticleModel extends Equatable {
|
||||
|
||||
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
|
||||
List<MatchedAssetModel> assets = [];
|
||||
final mList = json['matchedAssets'] ?? json['MatchedAssets'];
|
||||
final mList = json['matchedAssets'];
|
||||
if (mList != null && mList is List) {
|
||||
assets = mList.map((e) => MatchedAssetModel.fromJson(e as Map<String, dynamic>)).toList();
|
||||
assets = mList
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => MatchedAssetModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return NewsArticleModel(
|
||||
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
|
||||
title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title',
|
||||
author: json['author']?.toString() ?? json['Author']?.toString() ?? 'Unknown',
|
||||
summary: json['summary']?.toString() ?? json['Summary']?.toString() ?? '',
|
||||
contentRaw: json['contentRaw']?.toString() ?? json['ContentRaw']?.toString() ?? '',
|
||||
sourceUrl: json['sourceUrl']?.toString() ?? json['SourceUrl']?.toString() ?? '',
|
||||
scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? json['ScrapedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? json['PublishedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
status: json['status']?.toString() ?? json['Status']?.toString() ?? 'Completed',
|
||||
sentiment: json['sentiment']?.toString() ?? json['Sentiment']?.toString() ?? '',
|
||||
|
||||
sentimentScore: (json['sentimentScore'] ?? json['SentimentScore'] ?? 0.0).toDouble(),
|
||||
confidence: (json['confidence'] ?? json['Confidence'] ?? 0.0).toDouble(),
|
||||
finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null)
|
||||
? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult'])
|
||||
id: json['id']?.toString() ?? '',
|
||||
title: json['title']?.toString() ?? 'No Title',
|
||||
author: json['author']?.toString() ?? 'Unknown',
|
||||
summary: json['summary']?.toString() ?? '',
|
||||
contentRaw: json['contentRaw']?.toString() ?? '',
|
||||
sourceUrl: json['sourceUrl']?.toString() ?? '',
|
||||
scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
status: json['status']?.toString() ?? 'Completed',
|
||||
sentiment: json['sentiment']?.toString() ?? '',
|
||||
sentimentScore: (json['sentimentScore'] as num?)?.toDouble() ?? 0.0,
|
||||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||||
finbertResult: json['finbertResult'] != null
|
||||
? FinbertResultModel.fromJson(json['finbertResult'] as Map<String, dynamic>)
|
||||
: null,
|
||||
matchedAssets: assets,
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ class NewsRepository {
|
||||
String? symbol,
|
||||
String? isin,
|
||||
String? date,
|
||||
String? query,
|
||||
bool? hasSentiment,
|
||||
}) async {
|
||||
try {
|
||||
final Map<String, dynamic> queryParams = {
|
||||
@@ -33,17 +35,21 @@ class NewsRepository {
|
||||
if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol;
|
||||
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
|
||||
if (date != null && date.isNotEmpty) queryParams['date'] = date;
|
||||
if (query != null && query.isNotEmpty) queryParams['query'] = query;
|
||||
if (hasSentiment == true) queryParams['hasSentiment'] = true;
|
||||
|
||||
final response = await apiClient.get('/api/v1/news', queryParameters: queryParams);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
if (response.statusCode == 200 && response.data is List) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => NewsArticleModel.fromJson(json)).toList();
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((json) => NewsArticleModel.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
print('Error fetching news: $e');
|
||||
throw Exception('Failed to load news');
|
||||
throw Exception('Failed to load news: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,26 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../models/news_article_model.dart';
|
||||
import '../repositories/news_repository.dart';
|
||||
import '../widgets/news_card_item.dart';
|
||||
import '../widgets/advanced_news_filter_bar.dart';
|
||||
|
||||
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting.
|
||||
class NewsFeedScreen extends StatefulWidget {
|
||||
final ApiClient apiClient;
|
||||
final NewsRepository? repository;
|
||||
|
||||
const NewsFeedScreen({super.key, required this.apiClient});
|
||||
const NewsFeedScreen({super.key, required this.apiClient, this.repository});
|
||||
|
||||
@override
|
||||
State<NewsFeedScreen> createState() => _NewsFeedScreenState();
|
||||
}
|
||||
|
||||
class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||
late final NewsRepository _repository;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<dynamic> _newsItems = [];
|
||||
final List<NewsArticleModel> _newsItems = [];
|
||||
int _currentPage = 1;
|
||||
static const int _pageSize = 15;
|
||||
bool _isLoading = false;
|
||||
@@ -34,6 +38,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = widget.repository ?? NewsRepository(apiClient: widget.apiClient, backendUrl: ApiClient.baseUrl);
|
||||
_loadNews(refresh: true);
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||
@@ -49,17 +54,6 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
DateTime _parseDateTime(dynamic val) {
|
||||
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final str = val.toString().trim();
|
||||
if (str.isEmpty) return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
try {
|
||||
return DateTime.parse(str).toUtc();
|
||||
} catch (_) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNews({bool refresh = false}) async {
|
||||
if (_isLoading) return;
|
||||
if (refresh) {
|
||||
@@ -72,54 +66,32 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': _currentPage,
|
||||
'pageSize': _pageSize,
|
||||
};
|
||||
final fetched = await _repository.fetchNews(
|
||||
page: _currentPage,
|
||||
pageSize: _pageSize,
|
||||
date: _selectedDate?.toIso8601String().substring(0, 10),
|
||||
query: _searchQuery?.trim(),
|
||||
isin: _selectedIsin?.trim(),
|
||||
hasSentiment: _hasSentimentOnly,
|
||||
);
|
||||
|
||||
if (_selectedDate != null) {
|
||||
queryParams['date'] = _selectedDate!.toIso8601String().substring(0, 10);
|
||||
}
|
||||
if (_searchQuery != null && _searchQuery!.trim().isNotEmpty) {
|
||||
queryParams['query'] = _searchQuery!.trim();
|
||||
}
|
||||
if (_selectedIsin != null && _selectedIsin!.trim().isNotEmpty) {
|
||||
queryParams['isin'] = _selectedIsin!.trim();
|
||||
}
|
||||
if (_hasSentimentOnly) {
|
||||
queryParams['hasSentiment'] = true;
|
||||
}
|
||||
|
||||
final res = await widget.apiClient.get('/api/v1/news', queryParameters: queryParams);
|
||||
|
||||
if (res.statusCode == 200 && res.data != null && res.data is List) {
|
||||
final List fetched = res.data as List;
|
||||
setState(() {
|
||||
// Deduplicate by ID
|
||||
final existingIds = _newsItems.map((e) => e['id'] ?? e['Id']).where((id) => id != null).toSet();
|
||||
for (final item in fetched) {
|
||||
final id = item['id'] ?? item['Id'];
|
||||
if (id == null || !existingIds.contains(id)) {
|
||||
_newsItems.add(item);
|
||||
if (id != null) existingIds.add(id);
|
||||
}
|
||||
setState(() {
|
||||
final existingIds = _newsItems.map((e) => e.id).where((id) => id.isNotEmpty).toSet();
|
||||
for (final item in fetched) {
|
||||
if (item.id.isEmpty || !existingIds.contains(item.id)) {
|
||||
_newsItems.add(item);
|
||||
if (item.id.isNotEmpty) existingIds.add(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort strictly by publication timestamp descending (newest articles at the top)
|
||||
_newsItems.sort((a, b) {
|
||||
final dtA = _parseDateTime(a['publishedAt'] ?? a['PublishedAt'] ?? a['scrapedAt'] ?? a['ScrapedAt']);
|
||||
final dtB = _parseDateTime(b['publishedAt'] ?? b['PublishedAt'] ?? b['scrapedAt'] ?? b['ScrapedAt']);
|
||||
return dtB.compareTo(dtA);
|
||||
});
|
||||
_newsItems.sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
|
||||
|
||||
_currentPage++;
|
||||
if (fetched.length < _pageSize) {
|
||||
_hasMore = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
_currentPage++;
|
||||
if (fetched.length < _pageSize) {
|
||||
_hasMore = false;
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Handle error visually if necessary, currently silent fallback
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../models/news_article_model.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/status_badge.dart';
|
||||
import 'finbert_sentiment_tab.dart';
|
||||
|
||||
/// Two-Tab Dialog for Article details and real FinBERT Sentiment Analysis.
|
||||
class ArticleSentimentDialog extends StatefulWidget {
|
||||
final NewsArticleModel articleData;
|
||||
|
||||
@@ -32,17 +32,6 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _findString(List<String> keys) {
|
||||
final artMap = widget.articleData.toJson();
|
||||
for (final k in keys) {
|
||||
if (artMap.containsKey(k) && artMap[k] != null) {
|
||||
final val = artMap[k].toString().trim();
|
||||
if (val.isNotEmpty) return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _openOriginalSource() async {
|
||||
final urlStr = widget.articleData.sourceUrl;
|
||||
if (urlStr.isNotEmpty) {
|
||||
@@ -53,364 +42,160 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get _realSentimentData {
|
||||
if (widget.articleData.finbertResult != null) {
|
||||
return {'finbert_result': widget.articleData.finbertResult!.toJson()};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get _isLoadingSentiment => false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = _findString(['Title', 'title']) ?? 'Nachrichtenartikel';
|
||||
final author = _findString(['Author', 'author', 'Source', 'source']) ?? 'Finlytic News';
|
||||
final summary = _findString(['Summary', 'summary']);
|
||||
final sourceUrl = _findString(['SourceUrl', 'sourceUrl']);
|
||||
final contentRaw = _findString(['ContentRaw', 'contentRaw', 'content', 'Text', 'text']);
|
||||
final publishedAt = _findString(['PublishedAt', 'publishedAt', 'ScrapedAt', 'scrapedAt']) ?? '';
|
||||
final status = _findString(['Status', 'status']) ?? 'Completed';
|
||||
final rawSentiment = _findString(['sentiment', 'Sentiment', 'sentimentLabel', 'SentimentLabel']);
|
||||
final article = widget.articleData;
|
||||
final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel';
|
||||
final author = article.author.isNotEmpty ? article.author : 'Finlytic News';
|
||||
final summary = article.summary;
|
||||
final sourceUrl = article.sourceUrl;
|
||||
final contentRaw = article.contentRaw;
|
||||
final publishedAt = "${article.publishedAt.day}.${article.publishedAt.month}.${article.publishedAt.year}";
|
||||
final status = article.status.isNotEmpty ? article.status : 'Completed';
|
||||
final rawSentiment = article.sentiment;
|
||||
|
||||
final articleMap = widget.articleData.toJson();
|
||||
final Map<String, dynamic> articleObj = articleMap.containsKey('article') && articleMap['article'] is Map
|
||||
? Map<String, dynamic>.from(articleMap['article'])
|
||||
: articleMap;
|
||||
final matchedAssetsRaw = articleObj['MatchedAssets'] ?? articleObj['matchedAssets'] ?? articleMap['MatchedAssets'] ?? articleMap['matchedAssets'];
|
||||
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
|
||||
final compoundScore = article.finbertResult?.score ?? article.sentimentScore;
|
||||
|
||||
final double compoundScore = widget.articleData.finbertResult?.score ?? widget.articleData.sentimentScore;
|
||||
final double confidenceScore = widget.articleData.confidence;
|
||||
final String label = (widget.articleData.finbertResult?.label ?? widget.articleData.sentiment).toString().toUpperCase();
|
||||
|
||||
final double posRatio = widget.articleData.finbertResult?.positiveProbability ?? 0.0;
|
||||
final double neuRatio = widget.articleData.finbertResult?.neutralProbability ?? 0.0;
|
||||
final double negRatio = widget.articleData.finbertResult?.negativeProbability ?? 0.0;
|
||||
|
||||
final String aiText = widget.articleData.finbertResult?.summarySnippet ?? summary ?? 'FinBERT Sentiment-Analyse verarbeitet.';
|
||||
|
||||
|
||||
final Widget listBadge = rawSentiment != null
|
||||
final Widget listBadge = rawSentiment.isNotEmpty
|
||||
? StatusBadge.sentiment(rawSentiment.toUpperCase(), score: compoundScore)
|
||||
: StatusBadge(
|
||||
label: status.toUpperCase(),
|
||||
color: status.toLowerCase().contains('analyz') || status.toLowerCase().contains('klassifi')
|
||||
? AppTheme.primaryEmerald
|
||||
: AppTheme.accentCyan,
|
||||
color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan,
|
||||
);
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
|
||||
child: Container(
|
||||
width: 700,
|
||||
height: 620,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppTheme.primaryEmerald,
|
||||
labelColor: AppTheme.primaryEmerald,
|
||||
unselectedLabelColor: AppTheme.textMuted,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.article_outlined), text: 'Artikel & Volltext'),
|
||||
Tab(icon: Icon(Icons.psychology_outlined), text: 'Sentiment-Analyse (FinBERT)'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Tab 1: Artikel & Volltext
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.3)),
|
||||
),
|
||||
if (sourceUrl != null && sourceUrl.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(Icons.open_in_new, color: AppTheme.primaryEmerald, size: 22),
|
||||
tooltip: 'Originalquelle lesen',
|
||||
onPressed: _openOriginalSource,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$author ${publishedAt.isNotEmpty ? "• $publishedAt" : ""}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
listBadge,
|
||||
],
|
||||
),
|
||||
if (matchedAssets.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Verknüpfte Wertpapiere (Matched Assets):', style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: matchedAssets.map((a) {
|
||||
final name = a['Name'] ?? a['name'] ?? a['Isin'] ?? a['isin'] ?? 'Asset';
|
||||
final isin = a['Isin'] ?? a['isin'] ?? '';
|
||||
return Chip(
|
||||
label: Text(
|
||||
isin.toString().isNotEmpty && name.toString() != isin.toString()
|
||||
? '${name.toString()} ($isin)'
|
||||
: name.toString(),
|
||||
style: TextStyle(fontSize: 11, color: AppTheme.accentCyan),
|
||||
),
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
Divider(height: 24, color: AppTheme.glassBorder),
|
||||
if (summary != null && summary.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.accentCyan, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(summary, style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
contentRaw ?? summary ?? 'Kein Volltext verfügbar.',
|
||||
style: TextStyle(fontSize: 13.5, height: 1.6, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tab 2: Real Sentiment Analysis (FinBERT)
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('FinBERT KI Klassifizierung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (_realSentimentData != null) StatusBadge.sentiment(label, score: compoundScore),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLoadingSentiment)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else if (_realSentimentData == null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(top: 20),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 44),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Keine Sentiment-Analyse vorhanden',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Für diesen Artikel liegt aktuell noch keine FinBERT-Sentiment-Analyse vor.',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.textMuted, height: 1.4),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Row(
|
||||
children: [
|
||||
SentimentMetricCard(
|
||||
title: 'Compound Score',
|
||||
valueText: '${compoundScore > 0 ? "+" : ""}${compoundScore.toStringAsFixed(2)}',
|
||||
tooltipText: 'Der Compound-Score misst die aggregierte Gesamtausrichtung der Nachricht auf einer Skala von -1.00 (sehr negativ) bis +1.00 (sehr positiv). Werte ab +0.15 gelten als positiv.',
|
||||
accentColor: compoundScore > 0.15
|
||||
? AppTheme.primaryEmerald
|
||||
: (compoundScore < -0.15 ? AppTheme.accentRed : AppTheme.accentCyan),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SentimentMetricCard(
|
||||
title: 'KI-Confidence',
|
||||
valueText: '${(confidenceScore * 100).toInt()}%',
|
||||
tooltipText: 'Die Confidence gibt die statistische Wahrscheinlichkeit (0% bis 100%) an, mit welcher die FinBERT KI ihre Stimmungszuordnung berechnet hat.',
|
||||
accentColor: AppTheme.accentCyan,
|
||||
progressValue: confidenceScore,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Echte FinBERT Softmax-Verteilung:', style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
ArticleSentimentMeterBar(label: 'Positiv', ratio: posRatio, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 6),
|
||||
ArticleSentimentMeterBar(label: 'Neutral', ratio: neuRatio, color: AppTheme.accentCyan),
|
||||
const SizedBox(height: 6),
|
||||
ArticleSentimentMeterBar(label: 'Negativ', ratio: negRatio, color: AppTheme.accentRed),
|
||||
Divider(height: 24, color: AppTheme.glassBorder),
|
||||
const Text('KI-Zusammenfassung & Auswirkung:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
aiText,
|
||||
style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracted metric card component with interactive info explanation tooltips.
|
||||
class SentimentMetricCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String valueText;
|
||||
final String tooltipText;
|
||||
final Color accentColor;
|
||||
final double? progressValue;
|
||||
|
||||
const SentimentMetricCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.valueText,
|
||||
required this.tooltipText,
|
||||
required this.accentColor,
|
||||
this.progressValue,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
width: 650,
|
||||
height: 600,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.textMuted, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Quelle: $author • $publishedAt',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: tooltipText,
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E2130),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
textStyle: const TextStyle(color: Colors.white, fontSize: 12, height: 1.3),
|
||||
child: Icon(Icons.info_outline, size: 16, color: AppTheme.accentCyan),
|
||||
const SizedBox(width: 8),
|
||||
listBadge,
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
valueText,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: accentColor),
|
||||
),
|
||||
if (progressValue != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
LinearProgressIndicator(
|
||||
value: progressValue!.clamp(0.0, 1.0),
|
||||
backgroundColor: AppTheme.glassBorder,
|
||||
color: accentColor,
|
||||
minHeight: 6,
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.glassBorder),
|
||||
),
|
||||
],
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppTheme.primaryEmerald,
|
||||
labelColor: AppTheme.primaryEmerald,
|
||||
unselectedLabelColor: AppTheme.textMuted,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel-Inhalt'),
|
||||
Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT Sentiment'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (summary.isNotEmpty) ...[
|
||||
const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(summary, style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (article.matchedAssets.isNotEmpty) ...[
|
||||
const Text('Zugeordnete Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: article.matchedAssets.map((asset) {
|
||||
return Chip(
|
||||
label: Text('${asset.symbol} (${asset.isin})', style: const TextStyle(fontSize: 11, color: Colors.white)),
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
side: BorderSide(color: AppTheme.glassBorder),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
contentRaw.isNotEmpty ? contentRaw : 'Kein vollständiger Text verfügbar.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FinbertSentimentTab(article: article),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (sourceUrl.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: _openOriginalSource,
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: const Text('Originalquelle im Browser öffnen'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black),
|
||||
child: const Text('Schließen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracted sub-component for displaying sentiment probability bars.
|
||||
class ArticleSentimentMeterBar extends StatelessWidget {
|
||||
final String label;
|
||||
final double ratio;
|
||||
final Color color;
|
||||
|
||||
const ArticleSentimentMeterBar({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.ratio,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clampedRatio = ratio.clamp(0.0, 1.0);
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 60, child: Text(label, style: const TextStyle(fontSize: 12))),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: clampedRatio,
|
||||
backgroundColor: AppTheme.glassSurface,
|
||||
color: color,
|
||||
minHeight: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text('${(clampedRatio * 100).toInt()}%', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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/news_article_model.dart';
|
||||
|
||||
class FinbertSentimentTab extends StatelessWidget {
|
||||
final NewsArticleModel article;
|
||||
|
||||
const FinbertSentimentTab({super.key, required this.article});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final finbert = article.finbertResult;
|
||||
final double compoundScore = finbert?.score ?? article.sentimentScore;
|
||||
final double confidenceScore = article.confidence;
|
||||
final String label = (finbert?.label ?? article.sentiment).toUpperCase();
|
||||
|
||||
final double posRatio = finbert?.positiveProbability ?? (label == 'POSITIVE' ? 0.8 : 0.1);
|
||||
final double neuRatio = finbert?.neutralProbability ?? (label == 'NEUTRAL' ? 0.8 : 0.1);
|
||||
final double negRatio = finbert?.negativeProbability ?? (label == 'NEGATIVE' ? 0.8 : 0.1);
|
||||
|
||||
final String aiText = finbert?.summarySnippet ?? (article.summary.isNotEmpty ? article.summary : 'FinBERT Sentiment-Analyse verarbeitet.');
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.psychology, color: AppTheme.accentCyan, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text('FinBERT NLP Modell', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
StatusBadge.sentiment(label.isNotEmpty ? label : 'NEUTRAL', score: compoundScore),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Sentiment Verteilung (Wahrscheinlichkeiten):', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
const SizedBox(height: 12),
|
||||
_buildProbBar('Positiv', posRatio, AppTheme.primaryEmerald),
|
||||
const SizedBox(height: 8),
|
||||
_buildProbBar('Neutral', neuRatio, Colors.amber),
|
||||
const SizedBox(height: 8),
|
||||
_buildProbBar('Negativ', negRatio, AppTheme.accentRed),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Konfidenz-Score:', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text('${(confidenceScore * 100).toStringAsFixed(1)}%', style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Compound Sentiment-Wert:', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(compoundScore.toStringAsFixed(2), style: TextStyle(color: compoundScore >= 0 ? AppTheme.primaryEmerald : AppTheme.accentRed, fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('KI-Zusammenfassung & Relevanz:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
Text(aiText, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProbBar(String label, double ratio, Color color) {
|
||||
final pct = (ratio * 100).clamp(0.0, 100.0);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
|
||||
Text('${pct.toStringAsFixed(1)}%', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.08),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user