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
@@ -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<CorporateEventModel> get filteredEvents {
return allEvents.where((e) {
if (selectedCategory != 'Alle' && e.eventType != selectedCategory) {
if (!matchesCategory(e.eventType, selectedCategory)) {
return false;
}
if (selectedDate != null) {
@@ -33,7 +33,7 @@ class _CorporateCalendarScreenContent extends StatelessWidget {
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
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),
@@ -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(
@@ -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 {
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
return;
@@ -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<NewsFeedScreen> {
final ScrollController _scrollController = ScrollController();
final List<NewsArticleModel> _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<NewsFeedScreen> {
DateTime? _selectedDate;
String? _selectedIsin;
bool _hasSentimentOnly = false;
String? _selectedSentimentFilter; // null, 'POSITIVE', 'NEUTRAL', 'NEGATIVE'
Timer? _debounceTimer;
@override
@@ -72,7 +73,7 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
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<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) {
_searchQuery = val;
_debounceTimer?.cancel();
@@ -120,25 +142,39 @@ class _NewsFeedScreenState extends State<NewsFeedScreen> {
_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<NewsFeedScreen> {
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<NewsFeedScreen> {
);
}
}
@@ -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,
),
),
],
),
),
);
}
}
@@ -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<NewsArticleModel>? 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<ArticleSentimentDialog> 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<ArticleSentimentDialog> 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<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
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<ArticleSentimentDialog> with Si
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(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(
@@ -70,13 +131,15 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> 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<ArticleSentimentDialog> 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<ArticleSentimentDialog> 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<ArticleSentimentDialog> 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<ArticleSentimentDialog> 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<ArticleSentimentDialog> with Si
),
),
const SizedBox(height: 12),
// Footer
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -181,15 +344,20 @@ class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> 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)),
),
],
),
@@ -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<NewsArticleModel>? 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(),