feat(app): clean architecture with typed repositories, DTO models and calendar event logos

This commit is contained in:
2026-08-15 01:03:22 +02:00
parent f08fecde23
commit 15f8f7896e
34 changed files with 1435 additions and 1420 deletions
@@ -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,
),
),
],
);
}
}