feat(news,calendar): improve corporate calendar filters and news sentiment dialogs
This commit is contained in:
@@ -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;
|
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
|
@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;
|
||||||
|
typeLabel = 'Zahlungstag';
|
||||||
|
} else {
|
||||||
|
badgeColor = AppTheme.accentCyan;
|
||||||
|
typeLabel = 'Ex-Dividende';
|
||||||
|
}
|
||||||
|
} else if (tLower.contains('pay') || tLower.contains('zahl') || tLower.contains('auszahl')) {
|
||||||
badgeColor = Colors.amber;
|
badgeColor = Colors.amber;
|
||||||
typeLabel = 'Zahlungstag';
|
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: [
|
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),
|
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),
|
||||||
listBadge,
|
Row(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () => Navigator.pop(context),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
icon: const Icon(Icons.close, color: Colors.white70),
|
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),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
if (badgeWidget != null) ...[
|
||||||
//badgeWidget,
|
const SizedBox(width: 8),
|
||||||
|
badgeWidget,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (summary.isNotEmpty) ...[
|
||||||
Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
|
const SizedBox(height: 8),
|
||||||
|
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: () {
|
||||||
Navigator.push(
|
if (isin.isNotEmpty) {
|
||||||
context,
|
Navigator.push(
|
||||||
MaterialPageRoute(
|
context,
|
||||||
builder: (_) => AssetDetailScreen(
|
MaterialPageRoute(
|
||||||
isin: assetItem,
|
builder: (_) => AssetDetailScreen(
|
||||||
name: name,
|
isin: isin,
|
||||||
symbol: symbol != null ? symbol.symbol : null,
|
name: name,
|
||||||
apiClient: apiClient,
|
symbol: symbol != null ? symbol.symbol : null,
|
||||||
|
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." });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user