diff --git a/FinlyticApp/lib/core/widgets/status_badge.dart b/FinlyticApp/lib/core/widgets/status_badge.dart index 746c099..16ed4c2 100644 --- a/FinlyticApp/lib/core/widgets/status_badge.dart +++ b/FinlyticApp/lib/core/widgets/status_badge.dart @@ -15,15 +15,26 @@ class StatusBadge extends StatelessWidget { }); factory StatusBadge.sentiment(String status, {double? score}) { - Color bg = AppTheme.textMuted; - if (status.toUpperCase().contains('POS') || (score != null && score > 0.15)) { + final sUpper = status.trim().toUpperCase(); + Color bg; + if (sUpper.contains('POS')) { bg = AppTheme.primaryEmerald; - } else if (status.toUpperCase().contains('NEG') || (score != null && score < -0.15)) { + } else if (sUpper.contains('NEG')) { 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; + } + } else { + bg = AppTheme.textMuted; } - return StatusBadge(label: status.toUpperCase(), color: bg); + return StatusBadge(label: sUpper.isNotEmpty ? sUpper : 'NEUTRAL', color: bg); } @override diff --git a/FinlyticApp/lib/features/calendar/bloc/calendar_state.dart b/FinlyticApp/lib/features/calendar/bloc/calendar_state.dart index 6aaed39..5320a41 100644 --- a/FinlyticApp/lib/features/calendar/bloc/calendar_state.dart +++ b/FinlyticApp/lib/features/calendar/bloc/calendar_state.dart @@ -25,9 +25,29 @@ class CalendarLoaded extends CalendarState { 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 get filteredEvents { return allEvents.where((e) { - if (selectedCategory != 'Alle' && e.eventType != selectedCategory) { + if (!matchesCategory(e.eventType, selectedCategory)) { return false; } if (selectedDate != null) { diff --git a/FinlyticApp/lib/features/calendar/views/corporate_calendar_screen.dart b/FinlyticApp/lib/features/calendar/views/corporate_calendar_screen.dart index 6c0a465..c01e4e2 100644 --- a/FinlyticApp/lib/features/calendar/views/corporate_calendar_screen.dart +++ b/FinlyticApp/lib/features/calendar/views/corporate_calendar_screen.dart @@ -33,7 +33,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget { const _CorporateCalendarScreenContent({required this.apiClient}); - static const List categories = ['Alle', 'Earnings', 'ExDividend', 'Payout']; + static const List categories = ['Alle', 'Earnings', 'ExDividend', 'Payout', 'Split']; @override Widget build(BuildContext context) { @@ -86,6 +86,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget { if (cat == 'Earnings') label = 'Quartalsergebnisse'; if (cat == 'ExDividend') label = 'Ex-Dividendentage'; if (cat == 'Payout') label = 'Zahlungstage'; + if (cat == 'Split') label = 'Aktiensplits'; return Padding( padding: const EdgeInsets.only(right: 8), diff --git a/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart b/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart index e33f97c..130743d 100644 --- a/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart +++ b/FinlyticApp/lib/features/calendar/widgets/calendar_event_tile.dart @@ -36,12 +36,21 @@ class CalendarEventTile extends StatelessWidget { Color badgeColor = AppTheme.primaryEmerald; String typeLabel = 'Quartalszahlen'; - if (type == 'ExDividend') { - badgeColor = AppTheme.accentCyan; - typeLabel = 'Ex-Dividende'; - } else if (type == 'Payout') { + final tLower = type.toLowerCase(); + if (tLower.contains('ex') || tLower.contains('div') || tLower.contains('ausschütt')) { + if (tLower.contains('pay') || tLower.contains('zahl')) { + badgeColor = Colors.amber; + 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( diff --git a/FinlyticApp/lib/features/news/repositories/news_repository.dart b/FinlyticApp/lib/features/news/repositories/news_repository.dart index 9fd053d..1d6dc62 100644 --- a/FinlyticApp/lib/features/news/repositories/news_repository.dart +++ b/FinlyticApp/lib/features/news/repositories/news_repository.dart @@ -53,6 +53,19 @@ class NewsRepository { } } + Future 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 data = response.data is Map ? Map.from(response.data) : {}; + return NewsArticleModel.fromJson(data); + } + throw Exception('Unerwartete Server-Antwort'); + } catch (e) { + throw Exception('Sentiment-Analyse fehlgeschlagen: $e'); + } + } + Future connectToLiveFeed() async { if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) { return; diff --git a/FinlyticApp/lib/features/news/views/news_feed_screen.dart b/FinlyticApp/lib/features/news/views/news_feed_screen.dart index 0a76f31..bc5afe7 100644 --- a/FinlyticApp/lib/features/news/views/news_feed_screen.dart +++ b/FinlyticApp/lib/features/news/views/news_feed_screen.dart @@ -7,7 +7,7 @@ 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. +/// Paginated Infinite Scroll Daily News Feed screen with sentiment toggle filters, real-time update handling, and clean navigation. class NewsFeedScreen extends StatefulWidget { final ApiClient apiClient; final NewsRepository? repository; @@ -23,7 +23,7 @@ class _NewsFeedScreenState extends State { final ScrollController _scrollController = ScrollController(); final List _newsItems = []; int _currentPage = 1; - static const int _pageSize = 15; + static const int _pageSize = 20; bool _isLoading = false; bool _hasMore = true; @@ -32,7 +32,8 @@ class _NewsFeedScreenState extends State { DateTime? _selectedDate; String? _selectedIsin; bool _hasSentimentOnly = false; - + String? _selectedSentimentFilter; // null, 'POSITIVE', 'NEUTRAL', 'NEGATIVE' + Timer? _debounceTimer; @override @@ -72,7 +73,7 @@ class _NewsFeedScreenState extends State { date: _selectedDate?.toIso8601String().substring(0, 10), query: _searchQuery?.trim(), isin: _selectedIsin?.trim(), - hasSentiment: _hasSentimentOnly, + hasSentiment: _hasSentimentOnly || _selectedSentimentFilter != null, ); setState(() { @@ -97,6 +98,27 @@ class _NewsFeedScreenState extends State { } } + List 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) { _searchQuery = val; _debounceTimer?.cancel(); @@ -120,25 +142,39 @@ class _NewsFeedScreenState extends State { _selectedDate = null; _selectedIsin = null; _hasSentimentOnly = false; + _selectedSentimentFilter = null; }); _loadNews(refresh: true); } @override Widget build(BuildContext context) { + final displayedItems = _filteredNewsItems; + return Scaffold( body: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Marktnachrichten & Feed', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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), AdvancedNewsFilterBar( searchQuery: _searchQuery, selectedDate: _selectedDate, selectedIsin: _selectedIsin, hasSentimentOnly: _hasSentimentOnly, + selectedSentimentFilter: _selectedSentimentFilter, onSearchChanged: _onSearchChanged, onIsinChanged: _onIsinChanged, onDateChanged: (val) { @@ -149,27 +185,46 @@ class _NewsFeedScreenState extends State { setState(() => _hasSentimentOnly = val); _loadNews(refresh: true); }, + onSentimentFilterChanged: (val) { + setState(() => _selectedSentimentFilter = val); + }, onResetFilters: _resetFilters, ), const SizedBox(height: 16), Expanded( - child: _newsItems.isEmpty && !_isLoading + child: displayedItems.isEmpty && !_isLoading ? 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( controller: _scrollController, - itemCount: _newsItems.length + (_hasMore ? 1 : 0), + itemCount: displayedItems.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { - if (index == _newsItems.length) { + if (index == displayedItems.length) { return Center( child: Padding( 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 { ); } } - diff --git a/FinlyticApp/lib/features/news/widgets/advanced_news_filter_bar.dart b/FinlyticApp/lib/features/news/widgets/advanced_news_filter_bar.dart index 3a8b604..f27b6f7 100644 --- a/FinlyticApp/lib/features/news/widgets/advanced_news_filter_bar.dart +++ b/FinlyticApp/lib/features/news/widgets/advanced_news_filter_bar.dart @@ -7,10 +7,12 @@ class AdvancedNewsFilterBar extends StatelessWidget { final DateTime? selectedDate; final String? selectedIsin; final bool hasSentimentOnly; + final String? selectedSentimentFilter; // null (Alle), 'POSITIVE', 'NEUTRAL', 'NEGATIVE' final Function(String?) onSearchChanged; final Function(DateTime?) onDateChanged; final Function(String?) onIsinChanged; final Function(bool) onSentimentToggleChanged; + final Function(String?) onSentimentFilterChanged; final VoidCallback onResetFilters; const AdvancedNewsFilterBar({ @@ -19,10 +21,12 @@ class AdvancedNewsFilterBar extends StatelessWidget { required this.selectedDate, required this.selectedIsin, required this.hasSentimentOnly, + this.selectedSentimentFilter, required this.onSearchChanged, required this.onDateChanged, required this.onIsinChanged, required this.onSentimentToggleChanged, + required this.onSentimentFilterChanged, required this.onResetFilters, }); @@ -38,6 +42,7 @@ class AdvancedNewsFilterBar extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Row 1: Search Query & ISIN Input Row( children: [ Expanded( @@ -102,8 +107,14 @@ class AdvancedNewsFilterBar extends StatelessWidget { ], ), 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: [ + // Date picker InkWell( onTap: () async { final date = await showDatePicker( @@ -138,6 +149,7 @@ class AdvancedNewsFilterBar extends StatelessWidget { border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted), const SizedBox(width: 8), @@ -160,9 +172,10 @@ class AdvancedNewsFilterBar extends StatelessWidget { ), ), ), - const SizedBox(width: 16), + + // AI Analyzed Only Chip FilterChip( - label: const Text('Nur Analysiert (KI)'), + label: const Text('Nur KI-Analysiert'), selected: hasSentimentOnly, onSelected: onSentimentToggleChanged, backgroundColor: Colors.black.withValues(alpha: 0.2), @@ -171,14 +184,33 @@ class AdvancedNewsFilterBar extends StatelessWidget { side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder), labelStyle: TextStyle( 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( onPressed: onResetFilters, - icon: Icon(Icons.refresh, size: 16, color: AppTheme.textMuted), - label: Text('Reset Filter', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)), + icon: Icon(Icons.refresh, size: 15, color: AppTheme.textMuted), + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart b/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart index 7afae4c..1533dd7 100644 --- a/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart +++ b/FinlyticApp/lib/features/news/widgets/article_sentiment_dialog.dart @@ -1,16 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/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'; class ArticleSentimentDialog extends StatefulWidget { final NewsArticleModel articleData; + final ApiClient? apiClient; + final ValueChanged? onArticleUpdated; const ArticleSentimentDialog({ super.key, required this.articleData, + this.apiClient, + this.onArticleUpdated, }); @override @@ -19,10 +28,14 @@ class ArticleSentimentDialog extends StatefulWidget { class _ArticleSentimentDialogState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + late NewsArticleModel _currentArticle; + bool _isReanalyzing = false; + String? _reanalyzeError; @override void initState() { super.initState(); + _currentArticle = widget.articleData; _tabController = TabController(length: 2, vsync: this); } @@ -33,7 +46,7 @@ class _ArticleSentimentDialogState extends State with Si } void _openOriginalSource() async { - final urlStr = widget.articleData.sourceUrl; + final urlStr = _currentArticle.sourceUrl; if (urlStr.isNotEmpty) { final uri = Uri.parse(urlStr); if (await canLaunchUrl(uri)) { @@ -42,15 +55,60 @@ class _ArticleSentimentDialogState extends State with Si } } + Future _reanalyzeSentiment() async { + if (_isReanalyzing || _currentArticle.id.isEmpty) return; + + setState(() { + _isReanalyzing = true; + _reanalyzeError = null; + }); + + try { + final client = widget.apiClient ?? context.read(); + 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 Widget build(BuildContext context) { - final article = widget.articleData; + final article = _currentArticle; 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 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 rawSentiment = article.sentiment; @@ -63,6 +121,9 @@ class _ArticleSentimentDialogState extends State with Si color: status.toLowerCase().contains('analyz') ? AppTheme.primaryEmerald : AppTheme.accentCyan, ); + final client = widget.apiClient ?? (context.mounted ? context.read() : null); + final favourites = context.read().state.favoriteDetails; + return Dialog( backgroundColor: AppTheme.cardSurface, shape: RoundedRectangleBorder( @@ -70,13 +131,15 @@ class _ArticleSentimentDialogState extends State with Si side: BorderSide(color: AppTheme.glassBorder), ), child: Container( - width: 650, - height: 600, + width: 720, + height: 640, padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( @@ -88,7 +151,7 @@ class _ArticleSentimentDialogState extends State with Si maxLines: 2, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( 'Quelle: $author • $publishedAt', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), @@ -96,15 +159,64 @@ class _ArticleSentimentDialogState extends State with Si ], ), ), - const SizedBox(width: 8), - listBadge, - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, color: Colors.white70), + 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, + const SizedBox(width: 8), + IconButton( + onPressed: () => Navigator.pop(context), + 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), + + // Tab Bar Container( decoration: BoxDecoration( color: AppTheme.glassSurface, @@ -118,12 +230,14 @@ class _ArticleSentimentDialogState extends State with Si 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'), + Tab(icon: Icon(Icons.article_outlined, size: 18), text: 'Artikel & Assets'), + Tab(icon: Icon(Icons.psychology_outlined, size: 18), text: 'FinBERT KI-Sentiment'), ], ), ), const SizedBox(height: 12), + + // Tab Views Expanded( child: TabBarView( controller: _tabController, @@ -134,37 +248,84 @@ class _ArticleSentimentDialogState extends State with Si children: [ if (summary.isNotEmpty) ...[ const Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), - const SizedBox(height: 4), + const SizedBox(height: 6), Container( - padding: const EdgeInsets.all(10), + width: double.infinity, + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTheme.glassSurface, 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) ...[ - const Text('Zugeordnete Assets:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)), - const SizedBox(height: 6), + Row( + 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( - spacing: 6, + spacing: 8, + runSpacing: 8, 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), + final isin = asset.isin; + final name = asset.name.isNotEmpty ? asset.name : (asset.symbol.isNotEmpty ? asset.symbol : isin); + final match = favourites.where((e) => e.isin == isin); + 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(), ), - const SizedBox(height: 12), + const SizedBox(height: 14), ], 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), + Container( + width: double.infinity, + 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 with Si ), ), const SizedBox(height: 12), + + // Footer Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -181,15 +344,20 @@ class _ArticleSentimentDialogState extends State with Si TextButton.icon( onPressed: _openOriginalSource, 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), ) else const SizedBox.shrink(), ElevatedButton( onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryEmerald, foregroundColor: Colors.black), - child: const Text('Schließen'), + style: ElevatedButton.styleFrom( + 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)), ), ], ), diff --git a/FinlyticApp/lib/features/news/widgets/news_card_item.dart b/FinlyticApp/lib/features/news/widgets/news_card_item.dart index d157221..2fcb1a0 100644 --- a/FinlyticApp/lib/features/news/widgets/news_card_item.dart +++ b/FinlyticApp/lib/features/news/widgets/news_card_item.dart @@ -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:flutter/material.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 '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 { final dynamic item; final ApiClient apiClient; + final ValueChanged? onArticleUpdated; const NewsCardItem({ super.key, required this.item, required this.apiClient, + this.onArticleUpdated, }); @override @@ -44,7 +47,9 @@ class NewsCardItem extends StatelessWidget { final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString(); 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 matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : []; @@ -79,7 +84,11 @@ class NewsCardItem extends StatelessWidget { } showDialog( context: context, - builder: (_) => ArticleSentimentDialog(articleData: articleModel), + builder: (_) => ArticleSentimentDialog( + articleData: articleModel, + apiClient: apiClient, + onArticleUpdated: onArticleUpdated, + ), ); }, child: Column( @@ -89,43 +98,61 @@ class NewsCardItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ 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, + ), ), - const SizedBox(width: 8), - //badgeWidget, + if (badgeWidget != null) ...[ + const SizedBox(width: 8), + badgeWidget, + ], ], ), - const SizedBox(height: 8), - Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), + if (summary.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12.5), + ), + ], const SizedBox(height: 10), Text('$author • $pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)), if (matchedAssets.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( - spacing: 4, + spacing: 6, runSpacing: 4, children: matchedAssets.map((assetItem) { - final isin = assetItem['isin']!; - final name = assetItem['name']!; + final String isin = (assetItem is Map ? (assetItem['isin'] ?? assetItem['Isin']) : assetItem)?.toString() ?? ''; + 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 symbol = match.isEmpty ? null : match.first; 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, - padding: EdgeInsets.zero, + side: BorderSide(color: AppTheme.glassBorder), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AssetDetailScreen( - isin: assetItem, - name: name, - symbol: symbol != null ? symbol.symbol : null, - apiClient: apiClient, + if (isin.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AssetDetailScreen( + isin: isin, + name: name, + symbol: symbol != null ? symbol.symbol : null, + apiClient: apiClient, + ), ), - ), - ); + ); + } }, ); }).toList(), diff --git a/FinlyticBackend/Controllers/CalendarController.cs b/FinlyticBackend/Controllers/CalendarController.cs index bbc0421..9361b5e 100644 --- a/FinlyticBackend/Controllers/CalendarController.cs +++ b/FinlyticBackend/Controllers/CalendarController.cs @@ -105,8 +105,28 @@ public class CalendarController : ControllerBase !string.Equals(category, "Alle", StringComparison.OrdinalIgnoreCase) && !string.Equals(category, "all", StringComparison.OrdinalIgnoreCase)) { + string catKey = category.ToLowerInvariant(); 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) diff --git a/FinlyticBackend/Controllers/NewsController.cs b/FinlyticBackend/Controllers/NewsController.cs index ff50daf..9062748 100644 --- a/FinlyticBackend/Controllers/NewsController.cs +++ b/FinlyticBackend/Controllers/NewsController.cs @@ -240,4 +240,87 @@ public class NewsController : ControllerBase return NotFound(new { message = $"No sentiment analysis found for article {articleId}." }); } + + /// + /// Triggers or renews sentiment analysis for a specific news article via FinlyticSentiment. + /// + [HttpPost("sentiment/article/{articleId}/analyze")] + [HttpPost("{articleId}/reanalyze")] + public async Task 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( + "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( + "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." }); + } } \ No newline at end of file