feat(news,calendar): improve corporate calendar filters and news sentiment dialogs

This commit is contained in:
2026-08-15 19:30:00 +02:00
parent 067f1bfdd5
commit 960a4bdbd6
11 changed files with 556 additions and 87 deletions
+16 -5
View File
@@ -15,15 +15,26 @@ class StatusBadge extends StatelessWidget {
}); });
factory StatusBadge.sentiment(String status, {double? score}) { factory StatusBadge.sentiment(String status, {double? score}) {
Color bg = AppTheme.textMuted; final sUpper = status.trim().toUpperCase();
if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) { Color bg;
if (sUpper.contains('POS')) {
bg = AppTheme.primaryEmerald; bg = AppTheme.primaryEmerald;
} else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) { } else if (sUpper.contains('NEG')) {
bg = AppTheme.accentRed; bg = AppTheme.accentRed;
} else if (status.toUpperCase().contains('NEU')) { } else if (sUpper.contains('NEU')) {
bg = AppTheme.accentCyan;
} else if (score != null) {
if (score > 0.15) {
bg = AppTheme.primaryEmerald;
} else if (score < -0.15) {
bg = AppTheme.accentRed;
} else {
bg = AppTheme.accentCyan; bg = AppTheme.accentCyan;
} }
return StatusBadge(label: status.toUpperCase(), color: bg); } else {
bg = AppTheme.textMuted;
}
return StatusBadge(label: sUpper.isNotEmpty ? sUpper : 'NEUTRAL', color: bg);
} }
@override @override
@@ -25,9 +25,29 @@ class CalendarLoaded extends CalendarState {
this.selectedDate, this.selectedDate,
}); });
static bool matchesCategory(String eventType, String category) {
if (category == 'Alle' || category.isEmpty) return true;
final catKey = category.toLowerCase();
final t = eventType.toLowerCase();
if (catKey.contains('earn') || catKey.contains('quartal') || catKey.contains('ergebnis')) {
return t.contains('earn') || t.contains('quart') || t.contains('ergebnis') || t.contains('finan') || t.contains('report') || t.contains('bilanz') || t == 'event';
}
if (catKey.contains('ex') || catKey.contains('div')) {
return (t.contains('ex') || t.contains('div') || t.contains('ausschütt')) && !t.contains('pay') && !t.contains('zahl');
}
if (catKey.contains('pay') || catKey.contains('zahl')) {
return t.contains('pay') || t.contains('zahl') || t.contains('auszahl');
}
if (catKey.contains('split')) {
return t.contains('split');
}
return t.contains(catKey) || t == catKey;
}
List<CorporateEventModel> get filteredEvents { List<CorporateEventModel> get filteredEvents {
return allEvents.where((e) { return allEvents.where((e) {
if (selectedCategory != 'Alle' && e.eventType != selectedCategory) { if (!matchesCategory(e.eventType, selectedCategory)) {
return false; return false;
} }
if (selectedDate != null) { if (selectedDate != null) {
@@ -33,7 +33,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget {
const _CorporateCalendarScreenContent({required this.apiClient}); const _CorporateCalendarScreenContent({required this.apiClient});
static const List<String> categories = ['Alle', 'Earnings', 'ExDividend', 'Payout']; static const List<String> categories = ['Alle', 'Earnings', 'ExDividend', 'Payout', 'Split'];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -86,6 +86,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget {
if (cat == 'Earnings') label = 'Quartalsergebnisse'; if (cat == 'Earnings') label = 'Quartalsergebnisse';
if (cat == 'ExDividend') label = 'Ex-Dividendentage'; if (cat == 'ExDividend') label = 'Ex-Dividendentage';
if (cat == 'Payout') label = 'Zahlungstage'; if (cat == 'Payout') label = 'Zahlungstage';
if (cat == 'Split') label = 'Aktiensplits';
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
@@ -36,12 +36,21 @@ class CalendarEventTile extends StatelessWidget {
Color badgeColor = AppTheme.primaryEmerald; Color badgeColor = AppTheme.primaryEmerald;
String typeLabel = 'Quartalszahlen'; String typeLabel = 'Quartalszahlen';
if (type == 'ExDividend') { final tLower = type.toLowerCase();
badgeColor = AppTheme.accentCyan; if (tLower.contains('ex') || tLower.contains('div') || tLower.contains('ausschütt')) {
typeLabel = 'Ex-Dividende'; if (tLower.contains('pay') || tLower.contains('zahl')) {
} else if (type == 'Payout') {
badgeColor = Colors.amber; badgeColor = Colors.amber;
typeLabel = 'Zahlungstag'; typeLabel = 'Zahlungstag';
} else {
badgeColor = AppTheme.accentCyan;
typeLabel = 'Ex-Dividende';
}
} else if (tLower.contains('pay') || tLower.contains('zahl') || tLower.contains('auszahl')) {
badgeColor = Colors.amber;
typeLabel = 'Zahlungstag';
} else if (tLower.contains('split')) {
badgeColor = Colors.purpleAccent;
typeLabel = 'Aktiensplit';
} }
return GlassContainer( return GlassContainer(
@@ -53,6 +53,19 @@ class NewsRepository {
} }
} }
Future<NewsArticleModel> reanalyzeArticle(String articleId) async {
try {
final response = await apiClient.post('/api/v1/news/sentiment/article/$articleId/analyze');
if (response.statusCode == 200 && response.data != null) {
final Map<String, dynamic> data = response.data is Map ? Map<String, dynamic>.from(response.data) : {};
return NewsArticleModel.fromJson(data);
}
throw Exception('Unerwartete Server-Antwort');
} catch (e) {
throw Exception('Sentiment-Analyse fehlgeschlagen: $e');
}
}
Future<void> connectToLiveFeed() async { Future<void> connectToLiveFeed() async {
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) { if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
return; return;
@@ -7,7 +7,7 @@ import '../repositories/news_repository.dart';
import '../widgets/news_card_item.dart'; import '../widgets/news_card_item.dart';
import '../widgets/advanced_news_filter_bar.dart'; import '../widgets/advanced_news_filter_bar.dart';
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting. /// Paginated Infinite Scroll Daily News Feed screen with sentiment toggle filters, real-time update handling, and clean navigation.
class NewsFeedScreen extends StatefulWidget { class NewsFeedScreen extends StatefulWidget {
final ApiClient apiClient; final ApiClient apiClient;
final NewsRepository? repository; final NewsRepository? repository;
@@ -23,7 +23,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final List<NewsArticleModel> _newsItems = []; final List<NewsArticleModel> _newsItems = [];
int _currentPage = 1; int _currentPage = 1;
static const int _pageSize = 15; static const int _pageSize = 20;
bool _isLoading = false; bool _isLoading = false;
bool _hasMore = true; bool _hasMore = true;
@@ -32,6 +32,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
DateTime? _selectedDate; DateTime? _selectedDate;
String? _selectedIsin; String? _selectedIsin;
bool _hasSentimentOnly = false; bool _hasSentimentOnly = false;
String? _selectedSentimentFilter; // null, 'POSITIVE', 'NEUTRAL', 'NEGATIVE'
Timer? _debounceTimer; Timer? _debounceTimer;
@@ -72,7 +73,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
date: _selectedDate?.toIso8601String().substring(0, 10), date: _selectedDate?.toIso8601String().substring(0, 10),
query: _searchQuery?.trim(), query: _searchQuery?.trim(),
isin: _selectedIsin?.trim(), isin: _selectedIsin?.trim(),
hasSentiment: _hasSentimentOnly, hasSentiment: _hasSentimentOnly || _selectedSentimentFilter != null,
); );
setState(() { setState(() {
@@ -97,6 +98,27 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
} }
} }
List<NewsArticleModel> get _filteredNewsItems {
if (_selectedSentimentFilter == null) return _newsItems;
final target = _selectedSentimentFilter!.toUpperCase();
return _newsItems.where((item) {
final s = item.sentiment.toUpperCase();
if (target == 'POSITIVE') return s.contains('POS');
if (target == 'NEGATIVE') return s.contains('NEG');
if (target == 'NEUTRAL') return s.contains('NEU');
return s == target;
}).toList();
}
void _onArticleUpdated(NewsArticleModel updated) {
setState(() {
final idx = _newsItems.indexWhere((e) => e.id == updated.id);
if (idx != -1) {
_newsItems[idx] = updated;
}
});
}
void _onSearchChanged(String? val) { void _onSearchChanged(String? val) {
_searchQuery = val; _searchQuery = val;
_debounceTimer?.cancel(); _debounceTimer?.cancel();
@@ -120,25 +142,39 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
_selectedDate = null; _selectedDate = null;
_selectedIsin = null; _selectedIsin = null;
_hasSentimentOnly = false; _hasSentimentOnly = false;
_selectedSentimentFilter = null;
}); });
_loadNews(refresh: true); _loadNews(refresh: true);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final displayedItems = _filteredNewsItems;
return Scaffold( return Scaffold(
body: Padding( body: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
if (_newsItems.isNotEmpty)
Text(
'${displayedItems.length} Artikel angezeigt',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
AdvancedNewsFilterBar( AdvancedNewsFilterBar(
searchQuery: _searchQuery, searchQuery: _searchQuery,
selectedDate: _selectedDate, selectedDate: _selectedDate,
selectedIsin: _selectedIsin, selectedIsin: _selectedIsin,
hasSentimentOnly: _hasSentimentOnly, hasSentimentOnly: _hasSentimentOnly,
selectedSentimentFilter: _selectedSentimentFilter,
onSearchChanged: _onSearchChanged, onSearchChanged: _onSearchChanged,
onIsinChanged: _onIsinChanged, onIsinChanged: _onIsinChanged,
onDateChanged: (val) { onDateChanged: (val) {
@@ -149,27 +185,46 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
setState(() => _hasSentimentOnly = val); setState(() => _hasSentimentOnly = val);
_loadNews(refresh: true); _loadNews(refresh: true);
}, },
onSentimentFilterChanged: (val) {
setState(() => _selectedSentimentFilter = val);
},
onResetFilters: _resetFilters, onResetFilters: _resetFilters,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: _newsItems.isEmpty && !_isLoading child: displayedItems.isEmpty && !_isLoading
? Center( ? Center(
child: Text('Keine Nachrichten für diese Filterkriterien gefunden.', style: TextStyle(color: AppTheme.textMuted)), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.feed_outlined, size: 40, color: AppTheme.textMuted),
const SizedBox(height: 8),
Text('Keine Nachrichten für diese Filterkriterien gefunden.', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
const SizedBox(height: 10),
TextButton(
onPressed: _resetFilters,
child: Text('Filter zurücksetzen', style: TextStyle(color: AppTheme.accentCyan)),
),
],
),
) )
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
itemCount: _newsItems.length + (_hasMore ? 1 : 0), itemCount: displayedItems.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == _newsItems.length) { if (index == displayedItems.length) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: CircularProgressIndicator(color: AppTheme.primaryEmerald), child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
), ),
); );
} }
return NewsCardItem(item: _newsItems[index], apiClient: widget.apiClient); return NewsCardItem(
item: displayedItems[index],
apiClient: widget.apiClient,
onArticleUpdated: _onArticleUpdated,
);
}, },
), ),
), ),
@@ -179,4 +234,3 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
); );
} }
} }
@@ -7,10 +7,12 @@ class AdvancedNewsFilterBar extends StatelessWidget {
final DateTime? selectedDate; final DateTime? selectedDate;
final String? selectedIsin; final String? selectedIsin;
final bool hasSentimentOnly; final bool hasSentimentOnly;
final String? selectedSentimentFilter; // null (Alle), 'POSITIVE', 'NEUTRAL', 'NEGATIVE'
final Function(String?) onSearchChanged; final Function(String?) onSearchChanged;
final Function(DateTime?) onDateChanged; final Function(DateTime?) onDateChanged;
final Function(String?) onIsinChanged; final Function(String?) onIsinChanged;
final Function(bool) onSentimentToggleChanged; final Function(bool) onSentimentToggleChanged;
final Function(String?) onSentimentFilterChanged;
final VoidCallback onResetFilters; final VoidCallback onResetFilters;
const AdvancedNewsFilterBar({ const AdvancedNewsFilterBar({
@@ -19,10 +21,12 @@ class AdvancedNewsFilterBar extends StatelessWidget {
required this.selectedDate, required this.selectedDate,
required this.selectedIsin, required this.selectedIsin,
required this.hasSentimentOnly, required this.hasSentimentOnly,
this.selectedSentimentFilter,
required this.onSearchChanged, required this.onSearchChanged,
required this.onDateChanged, required this.onDateChanged,
required this.onIsinChanged, required this.onIsinChanged,
required this.onSentimentToggleChanged, required this.onSentimentToggleChanged,
required this.onSentimentFilterChanged,
required this.onResetFilters, required this.onResetFilters,
}); });
@@ -38,6 +42,7 @@ class AdvancedNewsFilterBar extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Row 1: Search Query & ISIN Input
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -102,8 +107,14 @@ class AdvancedNewsFilterBar extends StatelessWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row(
// Row 2: Date Picker, Sentiment Toggle Chips (Gut, Neutral, Schlecht), and Reset
Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
// Date picker
InkWell( InkWell(
onTap: () async { onTap: () async {
final date = await showDatePicker( final date = await showDatePicker(
@@ -138,6 +149,7 @@ class AdvancedNewsFilterBar extends StatelessWidget {
border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder), border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted), Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -160,9 +172,10 @@ class AdvancedNewsFilterBar extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(width: 16),
// AI Analyzed Only Chip
FilterChip( FilterChip(
label: const Text('Nur Analysiert (KI)'), label: const Text('Nur KI-Analysiert'),
selected: hasSentimentOnly, selected: hasSentimentOnly,
onSelected: onSentimentToggleChanged, onSelected: onSentimentToggleChanged,
backgroundColor: Colors.black.withValues(alpha: 0.2), backgroundColor: Colors.black.withValues(alpha: 0.2),
@@ -171,14 +184,33 @@ class AdvancedNewsFilterBar extends StatelessWidget {
side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder), side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder),
labelStyle: TextStyle( labelStyle: TextStyle(
color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.textMuted, color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.textMuted,
fontSize: 13, fontSize: 12,
), ),
), ),
const Spacer(),
// Sentiment Toggle Buttons (Alle, Gut, Neutral, Schlecht)
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_sentimentToggleItem('Alle', null, Colors.white70),
_sentimentToggleItem('Gut (Positiv)', 'POSITIVE', AppTheme.primaryEmerald, icon: Icons.trending_up),
_sentimentToggleItem('Neutral', 'NEUTRAL', AppTheme.accentCyan, icon: Icons.remove),
_sentimentToggleItem('Schlecht (Negativ)', 'NEGATIVE', AppTheme.accentRed, icon: Icons.trending_down),
],
),
),
// Reset Filter Button
TextButton.icon( TextButton.icon(
onPressed: onResetFilters, onPressed: onResetFilters,
icon: Icon(Icons.refresh, size: 16, color: AppTheme.textMuted), icon: Icon(Icons.refresh, size: 15, color: AppTheme.textMuted),
label: Text('Reset Filter', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), label: Text('Reset', style: TextStyle(color: AppTheme.textMuted, fontSize: 12)),
), ),
], ],
), ),
@@ -186,5 +218,36 @@ class AdvancedNewsFilterBar extends StatelessWidget {
), ),
); );
} }
}
Widget _sentimentToggleItem(String label, String? value, Color activeColor, {IconData? icon}) {
final isSelected = selectedSentimentFilter == value;
return GestureDetector(
onTap: () => onSentimentFilterChanged(value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: isSelected ? activeColor.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: isSelected ? Border.all(color: activeColor.withValues(alpha: 0.6)) : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: isSelected ? activeColor : AppTheme.textMuted),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
color: isSelected ? activeColor : AppTheme.textMuted,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 11.5,
),
),
],
),
),
);
}
}
@@ -1,16 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../models/news_article_model.dart'; import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/status_badge.dart'; import '../../../core/widgets/status_badge.dart';
import '../../asset_detail/views/asset_detail_screen.dart';
import '../../favorites/cubit/favorites_cubit.dart';
import '../models/news_article_model.dart';
import '../repositories/news_repository.dart';
import 'finbert_sentiment_tab.dart'; import 'finbert_sentiment_tab.dart';
class ArticleSentimentDialog extends StatefulWidget { class ArticleSentimentDialog extends StatefulWidget {
final NewsArticleModel articleData; final NewsArticleModel articleData;
final ApiClient? apiClient;
final ValueChanged<NewsArticleModel>? onArticleUpdated;
const ArticleSentimentDialog({ const ArticleSentimentDialog({
super.key, super.key,
required this.articleData, required this.articleData,
this.apiClient,
this.onArticleUpdated,
}); });
@override @override
@@ -19,10 +28,14 @@ class ArticleSentimentDialog extends StatefulWidget {
class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with SingleTickerProviderStateMixin { class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late NewsArticleModel _currentArticle;
bool _isReanalyzing = false;
String? _reanalyzeError;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentArticle = widget.articleData;
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
} }
@@ -33,7 +46,7 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
} }
void _openOriginalSource() async { void _openOriginalSource() async {
final urlStr = widget.articleData.sourceUrl; final urlStr = _currentArticle.sourceUrl;
if (urlStr.isNotEmpty) { if (urlStr.isNotEmpty) {
final uri = Uri.parse(urlStr); final uri = Uri.parse(urlStr);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
@@ -42,15 +55,60 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
} }
} }
Future<void> _reanalyzeSentiment() async {
if (_isReanalyzing || _currentArticle.id.isEmpty) return;
setState(() {
_isReanalyzing = true;
_reanalyzeError = null;
});
try {
final client = widget.apiClient ?? context.read<ApiClient>();
final repo = NewsRepository(apiClient: client, backendUrl: ApiClient.baseUrl);
final updatedArticle = await repo.reanalyzeArticle(_currentArticle.id);
if (mounted) {
setState(() {
_currentArticle = updatedArticle;
_isReanalyzing = false;
});
widget.onArticleUpdated?.call(updatedArticle);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: AppTheme.primaryEmerald, size: 20),
const SizedBox(width: 8),
const Text('Sentiment-Analyse erfolgreich erneuert!'),
],
),
backgroundColor: AppTheme.cardSurface,
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
setState(() {
_isReanalyzing = false;
_reanalyzeError = e.toString();
});
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final article = widget.articleData; final article = _currentArticle;
final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel'; final title = article.title.isNotEmpty ? article.title : 'Nachrichtenartikel';
final author = article.author.isNotEmpty ? article.author : 'Finlytic News'; final author = article.author.isNotEmpty ? article.author : 'Finlytic News';
final summary = article.summary; final summary = article.summary;
final sourceUrl = article.sourceUrl; final sourceUrl = article.sourceUrl;
final contentRaw = article.contentRaw; final contentRaw = article.contentRaw;
final publishedAt = "${article.publishedAt.day}.${article.publishedAt.month}.${article.publishedAt.year}"; final publishedAt = "${article.publishedAt.day.toString().padLeft(2, '0')}.${article.publishedAt.month.toString().padLeft(2, '0')}.${article.publishedAt.year}";
final status = article.status.isNotEmpty ? article.status : 'Completed'; final status = article.status.isNotEmpty ? article.status : 'Completed';
final rawSentiment = article.sentiment; final rawSentiment = article.sentiment;
@@ -63,6 +121,9 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan, color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan,
); );
final client = widget.apiClient ?? (context.mounted ? context.read<ApiClient>() : null);
final favourites = context.read<FavoritesCubit>().state.favoriteDetails;
return Dialog( return Dialog(
backgroundColor: AppTheme.cardSurface, backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -70,13 +131,15 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
side: BorderSide(color: AppTheme.glassBorder), side: BorderSide(color: AppTheme.glassBorder),
), ),
child: Container( child: Container(
width: 650, width: 720,
height: 600, height: 640,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
@@ -88,7 +151,7 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( Text(
'Quelle: $author$publishedAt', 'Quelle: $author$publishedAt',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12), style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
@@ -96,15 +159,64 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 12),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Sentiment Reanalyze Icon Button (directly left of badge)
IconButton(
tooltip: 'KI-Sentiment neu analysieren',
onPressed: _isReanalyzing ? null : _reanalyzeSentiment,
icon: _isReanalyzing
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.accentCyan),
)
: Icon(Icons.auto_awesome, size: 18, color: AppTheme.accentCyan),
padding: const EdgeInsets.all(6),
constraints: const BoxConstraints(),
),
const SizedBox(width: 6),
listBadge, listBadge,
const SizedBox(width: 8),
IconButton( IconButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, color: Colors.white70), icon: const Icon(Icons.close, color: Colors.white70, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
), ),
], ],
), ),
],
),
if (_reanalyzeError != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.accentRed.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 16, color: AppTheme.accentRed),
const SizedBox(width: 6),
Expanded(
child: Text(
_reanalyzeError!,
style: TextStyle(color: AppTheme.accentRed, fontSize: 11),
),
),
],
),
),
],
const SizedBox(height: 12), const SizedBox(height: 12),
// Tab Bar
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassSurface, color: AppTheme.glassSurface,
@@ -118,12 +230,14 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
unselectedLabelColor: AppTheme.textMuted, unselectedLabelColor: AppTheme.textMuted,
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
tabs: const [ tabs: const [
Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel-Inhalt'), Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel & Assets'),
Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT Sentiment'), Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT KI-Sentiment'),
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Tab Views
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@@ -134,37 +248,84 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
children: [ children: [
if (summary.isNotEmpty) ...[ if (summary.isNotEmpty) ...[
const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
const SizedBox(height: 4), const SizedBox(height: 6),
Container( Container(
padding: const EdgeInsets.all(10), width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassSurface, color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
), ),
child: Text(summary, style: const TextStyle(color: Colors.white70, fontSize: 13)), child: Text(summary, style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4)),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
], ],
if (article.matchedAssets.isNotEmpty) ...[ if (article.matchedAssets.isNotEmpty) ...[
const Text('Zugeordnete Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), Row(
const SizedBox(height: 6), children: [
const Text('Erkannte Unternehmen & Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
const SizedBox(width: 6),
Text('(Klick öffnet Asset-Details)', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
],
),
const SizedBox(height: 8),
Wrap( Wrap(
spacing: 6, spacing: 8,
runSpacing: 8,
children: article.matchedAssets.map((asset) { children: article.matchedAssets.map((asset) {
return Chip( final isin = asset.isin;
label: Text('${asset.symbol} (${asset.isin})', style: const TextStyle(fontSize: 11, color: Colors.white)), final name = asset.name.isNotEmpty ? asset.name : (asset.symbol.isNotEmpty ? asset.symbol : isin);
backgroundColor: AppTheme.glassSurface, final match = favourites.where((e) => e.isin == isin);
side: BorderSide(color: AppTheme.glassBorder), final symbol = match.isEmpty ? null : match.first;
return ActionChip(
avatar: Icon(Icons.open_in_new, size: 14, color: AppTheme.accentCyan),
label: Text(
name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.accentCyan,
),
),
backgroundColor: AppTheme.accentCyan.withValues(alpha: 0.1),
side: BorderSide(color: AppTheme.accentCyan.withValues(alpha: 0.35)),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
onPressed: () {
if (client != null && isin.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
isin: isin,
name: name,
symbol: symbol != null ? symbol.symbol : asset.symbol,
apiClient: client,
),
),
);
}
},
); );
}).toList(), }).toList(),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
], ],
const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Container(
contentRaw.isNotEmpty ? contentRaw : 'Kein vollständiger Text verfügbar.', width: double.infinity,
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.4), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Text(
contentRaw.isNotEmpty ? contentRaw : (summary.isNotEmpty ? summary : 'Kein vollständiger Text verfügbar.'),
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.5),
),
), ),
], ],
), ),
@@ -174,6 +335,8 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Footer
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -181,15 +344,20 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with Si
TextButton.icon( TextButton.icon(
onPressed: _openOriginalSource, onPressed: _openOriginalSource,
icon: const Icon(Icons.open_in_new, size: 16), icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Originalquelle im Browser öffnen'), label: const Text('Originalquelle im Web öffnen'),
style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan), style: TextButton.styleFrom(foregroundColor: AppTheme.accentCyan),
) )
else else
const SizedBox.shrink(), const SizedBox.shrink(),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black), style: ElevatedButton.styleFrom(
child: const Text('Schließen'), backgroundColor: AppTheme.primaryEmerald,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Schließen', style: TextStyle(fontWeight: FontWeight.bold)),
), ),
], ],
), ),
@@ -1,3 +1,4 @@
import 'package:finlytic_app/core/widgets/status_badge.dart';
import 'package:finlytic_app/features/favorites/cubit/favorites_cubit.dart'; import 'package:finlytic_app/features/favorites/cubit/favorites_cubit.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -10,15 +11,17 @@ import '../../asset_detail/views/asset_detail_screen.dart';
import '../models/news_article_model.dart'; import '../models/news_article_model.dart';
import 'article_sentiment_dialog.dart'; import 'article_sentiment_dialog.dart';
/// News Card Item displaying news info, tagged assets, timestamp, and sentiment. /// News Card Item displaying news info, tagged assets, timestamp, and sentiment badge (wie auf dem Dashboard).
class NewsCardItem extends StatelessWidget { class NewsCardItem extends StatelessWidget {
final dynamic item; final dynamic item;
final ApiClient apiClient; final ApiClient apiClient;
final ValueChanged<NewsArticleModel>? onArticleUpdated;
const NewsCardItem({ const NewsCardItem({
super.key, super.key,
required this.item, required this.item,
required this.apiClient, required this.apiClient,
this.onArticleUpdated,
}); });
@override @override
@@ -44,7 +47,9 @@ class NewsCardItem extends StatelessWidget {
final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString(); final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString();
final double? score = ((sentimentObj?['compoundScore'] ?? sentimentObj?['compound_score'] ?? article['sentimentScore']) as num?)?.toDouble(); final double? score = ((sentimentObj?['compoundScore'] ?? sentimentObj?['compound_score'] ?? article['sentimentScore']) as num?)?.toDouble();
final Widget? badgeWidget = (sentimentLabel != null && sentimentLabel.trim().isNotEmpty)
? StatusBadge.sentiment(sentimentLabel, score: score)
: null;
final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets']; final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets'];
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : []; final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
@@ -79,7 +84,11 @@ class NewsCardItem extends StatelessWidget {
} }
showDialog( showDialog(
context: context, context: context,
builder: (_) => ArticleSentimentDialog(articleData: articleModel), builder: (_) => ArticleSentimentDialog(
articleData: articleModel,
apiClient: apiClient,
onArticleUpdated: onArticleUpdated,
),
); );
}, },
child: Column( child: Column(
@@ -89,43 +98,61 @@ class NewsCardItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14.5),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
),
if (badgeWidget != null) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
//badgeWidget, badgeWidget,
],
], ],
), ),
if (summary.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), Text(
summary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12.5),
),
],
const SizedBox(height: 10), const SizedBox(height: 10),
Text('$author$pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), Text('$author$pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
if (matchedAssets.isNotEmpty) ...[ if (matchedAssets.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 4, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: matchedAssets.map((assetItem) { children: matchedAssets.map((assetItem) {
final isin = assetItem['isin']!; final String isin = (assetItem is Map ? (assetItem['isin'] ?? assetItem['Isin']) : assetItem)?.toString() ?? '';
final name = assetItem['name']!; final String name = (assetItem is Map ? (assetItem['name'] ?? assetItem['Name'] ?? assetItem['symbol'] ?? isin) : isin)?.toString() ?? isin;
final match = favourites.where((e) => e.isin == isin); final match = favourites.where((e) => e.isin == isin);
final symbol = match.isEmpty ? null : match.first; final symbol = match.isEmpty ? null : match.first;
return ActionChip( return ActionChip(
label: Text(name, style: TextStyle(fontSize: 10, color: AppTheme.accentCyan)), avatar: Icon(Icons.show_chart, size: 14, color: AppTheme.accentCyan),
label: Text(name, style: TextStyle(fontSize: 11, color: AppTheme.accentCyan, fontWeight: FontWeight.bold)),
backgroundColor: AppTheme.glassSurface, backgroundColor: AppTheme.glassSurface,
padding: EdgeInsets.zero, side: BorderSide(color: AppTheme.glassBorder),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () { onPressed: () {
if (isin.isNotEmpty) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AssetDetailScreen( builder: (_) => AssetDetailScreen(
isin: assetItem, isin: isin,
name: name, name: name,
symbol: symbol != null ? symbol.symbol : null, symbol: symbol != null ? symbol.symbol : null,
apiClient: apiClient, apiClient: apiClient,
), ),
), ),
); );
}
}, },
); );
}).toList(), }).toList(),
@@ -105,8 +105,28 @@ public class CalendarController : ControllerBase
!string.Equals(category, "Alle", StringComparison.OrdinalIgnoreCase) && !string.Equals(category, "Alle", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(category, "all", StringComparison.OrdinalIgnoreCase)) !string.Equals(category, "all", StringComparison.OrdinalIgnoreCase))
{ {
string catKey = category.ToLowerInvariant();
filtered = filtered.Where(e => filtered = filtered.Where(e =>
string.Equals(e.EventType, category, StringComparison.OrdinalIgnoreCase)); {
string t = (e.EventType ?? string.Empty).ToLowerInvariant();
if (catKey.Contains("earn") || catKey.Contains("quartal") || catKey.Contains("ergebnis"))
{
return t.Contains("earn") || t.Contains("quart") || t.Contains("ergebnis") || t.Contains("finan") || t.Contains("report") || t.Contains("bilanz") || string.Equals(t, "event", StringComparison.OrdinalIgnoreCase);
}
if (catKey.Contains("ex") || catKey.Contains("div"))
{
return (t.Contains("ex") || t.Contains("div") || t.Contains("ausschütt")) && !t.Contains("pay") && !t.Contains("zahl");
}
if (catKey.Contains("pay") || catKey.Contains("zahl"))
{
return t.Contains("pay") || t.Contains("zahl") || t.Contains("auszahl");
}
if (catKey.Contains("split"))
{
return t.Contains("split");
}
return t.Contains(catKey) || string.Equals(e.EventType, category, StringComparison.OrdinalIgnoreCase);
});
} }
if (date.HasValue) if (date.HasValue)
@@ -240,4 +240,87 @@ public class NewsController : ControllerBase
return NotFound(new { message = $"No sentiment analysis found for article {articleId}." }); return NotFound(new { message = $"No sentiment analysis found for article {articleId}." });
} }
/// <summary>
/// Triggers or renews sentiment analysis for a specific news article via FinlyticSentiment.
/// </summary>
[HttpPost("sentiment/article/{articleId}/analyze")]
[HttpPost("{articleId}/reanalyze")]
public async Task<IActionResult> ReanalyzeArticleSentiment(string articleId)
{
if (string.IsNullOrWhiteSpace(articleId)) return BadRequest(new { message = "ArticleId ist erforderlich." });
var targetId = articleId.Trim();
try
{
if (_mqttClient.IsConnected)
{
var rpcResult = await _mqttClient.SendRpcRequestAsync<IsinAnalysisEntry, AnalyzeSentimentRequest>(
"sentiment_Analyze",
new AnalyzeSentimentRequest(ArticleId: targetId, ForceReload: true),
TimeSpan.FromSeconds(20)
);
if (rpcResult != null)
{
// Fetch full article from FinlyticNews to return complete updated DTO
var article = await _mqttClient.SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
"news_GetById",
new ArticleRequest(targetId, targetId),
TimeSpan.FromSeconds(5)
);
if (article != null && rpcResult.FinbertResult != null)
{
var res = rpcResult.FinbertResult;
var enriched = article with
{
Sentiment = res.Label,
SentimentScore = res.CompoundScore,
Confidence = res.Confidence,
FinbertResult = res,
Status = "Analyzed"
};
return Ok(enriched);
}
if (article != null)
{
return Ok(article);
}
// Fallback to synthetic DTO from entry
var synthetic = new NewsArticleDto
{
Id = Guid.TryParse(targetId, out var g) ? g : Guid.NewGuid(),
Title = rpcResult.Article?.Title ?? "Artikel",
Author = rpcResult.Article?.Source ?? "FinlyticNews",
Summary = rpcResult.SummarySnippet,
ContentRaw = "",
SourceUrl = "",
ScrapedAt = DateTime.UtcNow,
PublishedAt = DateTime.TryParse(rpcResult.Article?.PublishedAt, out var pDate) ? pDate : DateTime.UtcNow,
Status = "Analyzed",
Sentiment = rpcResult.FinbertResult?.Label ?? "NEUTRAL",
SentimentScore = rpcResult.FinbertResult?.CompoundScore ?? 0.0,
Confidence = rpcResult.FinbertResult?.Confidence ?? 0.0,
FinbertResult = rpcResult.FinbertResult
};
return Ok(synthetic);
}
}
else
{
return StatusCode(503, new { message = "MQTT Broker nicht verbunden." });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim erneuten Analysieren des Artikels {ArticleId}", targetId);
return StatusCode(500, new { message = $"Analyse fehlgeschlagen: {ex.Message}" });
}
return NotFound(new { message = $"Artikel {targetId} konnte nicht analysiert werden." });
}
} }