feat(App): update Finlytic Flutter app UI and blocs

This commit is contained in:
2026-08-09 21:01:46 +02:00
parent e7427b7464
commit a708d2977c
591 changed files with 1095105 additions and 0 deletions
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:finlytic_app/features/news/repositories/news_repository.dart';
import 'news_event.dart';
import 'news_state.dart';
class NewsBloc extends Bloc<NewsEvent, NewsState> {
final NewsRepository repository;
static const int pageSize = 20;
StreamSubscription? _liveNewsSubscription;
NewsBloc({required this.repository}) : super(NewsInitial()) {
on<FetchNews>(_onFetchNews);
on<LoadMoreNews>(_onLoadMoreNews);
on<ReceiveLiveNews>(_onReceiveLiveNews);
// Subscribe to live news from SignalR
_liveNewsSubscription = repository.liveNewsStream.listen((article) {
add(ReceiveLiveNews(article));
});
// Connect to SignalR
repository.connectToLiveFeed();
}
Future<void> _onFetchNews(FetchNews event, Emitter<NewsState> emit) async {
try {
emit(NewsLoading());
final articles = await repository.fetchNews(
page: 1,
pageSize: pageSize,
symbol: event.symbol,
isin: event.isin,
date: event.date,
);
emit(NewsLoaded(
articles: articles,
hasReachedMax: articles.length < pageSize,
currentPage: 1,
symbol: event.symbol,
isin: event.isin,
date: event.date,
));
} catch (e) {
emit(NewsError("Failed to fetch news. Please try again."));
}
}
Future<void> _onLoadMoreNews(LoadMoreNews event, Emitter<NewsState> emit) async {
if (state is NewsLoaded) {
final currentState = state as NewsLoaded;
if (currentState.hasReachedMax) return;
try {
final nextPage = currentState.currentPage + 1;
final articles = await repository.fetchNews(
page: nextPage,
pageSize: pageSize,
symbol: currentState.symbol,
isin: currentState.isin,
date: currentState.date,
);
if (articles.isEmpty) {
emit(currentState.copyWith(hasReachedMax: true));
} else {
emit(NewsLoaded(
articles: currentState.articles + articles,
hasReachedMax: articles.length < pageSize,
currentPage: nextPage,
symbol: currentState.symbol,
isin: currentState.isin,
date: currentState.date,
));
}
} catch (e) {
emit(NewsError("Failed to load more news."));
}
}
}
void _onReceiveLiveNews(ReceiveLiveNews event, Emitter<NewsState> emit) {
if (state is NewsLoaded) {
final currentState = state as NewsLoaded;
// Check if the article matches current filters before prepending
bool matchesFilter = true;
if (currentState.symbol != null || currentState.isin != null) {
// Needs proper matching logic if needed, but for now we prepend if there's no filter or if it matches
// Assuming we prepend it regardless for live feed, or we could filter based on MatchedAssets if added to model.
}
if (matchesFilter) {
// Prepend new article to the top of the list!
final updatedArticles = [event.article, ...currentState.articles];
emit(currentState.copyWith(articles: updatedArticles));
}
}
}
@override
Future<void> close() {
_liveNewsSubscription?.cancel();
repository.dispose();
return super.close();
}
}
@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
abstract class NewsEvent extends Equatable {
const NewsEvent();
@override
List<Object?> get props => [];
}
class FetchNews extends NewsEvent {
final bool isRefresh;
final String? symbol;
final String? isin;
final String? date;
const FetchNews({this.isRefresh = false, this.symbol, this.isin, this.date});
@override
List<Object?> get props => [isRefresh, symbol, isin, date];
}
class LoadMoreNews extends NewsEvent {}
class ReceiveLiveNews extends NewsEvent {
final NewsArticleModel article;
const ReceiveLiveNews(this.article);
@override
List<Object?> get props => [article];
}
@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
abstract class NewsState extends Equatable {
const NewsState();
@override
List<Object?> get props => [];
}
class NewsInitial extends NewsState {}
class NewsLoading extends NewsState {}
class NewsLoaded extends NewsState {
final List<NewsArticleModel> articles;
final bool hasReachedMax;
final int currentPage;
// Filter state preservation
final String? symbol;
final String? isin;
final String? date;
const NewsLoaded({
required this.articles,
this.hasReachedMax = false,
this.currentPage = 1,
this.symbol,
this.isin,
this.date,
});
NewsLoaded copyWith({
List<NewsArticleModel>? articles,
bool? hasReachedMax,
int? currentPage,
String? symbol,
String? isin,
String? date,
}) {
return NewsLoaded(
articles: articles ?? this.articles,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
symbol: symbol ?? this.symbol,
isin: isin ?? this.isin,
date: date ?? this.date,
);
}
@override
List<Object?> get props => [articles, hasReachedMax, currentPage, symbol, isin, date];
}
class NewsError extends NewsState {
final String message;
const NewsError(this.message);
@override
List<Object?> get props => [message];
}
@@ -0,0 +1,59 @@
import 'package:equatable/equatable.dart';
class FinbertResultModel extends Equatable {
final String label;
final double score;
final double positiveProbability;
final double negativeProbability;
final double neutralProbability;
final String processingTimeMs;
final String? summarySnippet;
const FinbertResultModel({
required this.label,
required this.score,
required this.positiveProbability,
required this.negativeProbability,
required this.neutralProbability,
required this.processingTimeMs,
this.summarySnippet,
});
factory FinbertResultModel.fromJson(Map<String, dynamic> json) {
Map<String, dynamic>? probs = json['probabilities'] ?? json['Probabilities'];
return FinbertResultModel(
label: json['label'] ?? json['Label'] ?? 'NEUTRAL',
score: (json['compound_score'] ?? json['compoundScore'] ?? json['score'] ?? json['Score'] ?? 0.0).toDouble(),
positiveProbability: (probs != null ? (probs['positive'] ?? probs['Positive'] ?? 0.0) : (json['positiveProbability'] ?? json['PositiveProbability'] ?? 0.0)).toDouble(),
negativeProbability: (probs != null ? (probs['negative'] ?? probs['Negative'] ?? 0.0) : (json['negativeProbability'] ?? json['NegativeProbability'] ?? 0.0)).toDouble(),
neutralProbability: (probs != null ? (probs['neutral'] ?? probs['Neutral'] ?? 0.0) : (json['neutralProbability'] ?? json['NeutralProbability'] ?? 0.0)).toDouble(),
processingTimeMs: json['processingTimeMs']?.toString() ?? json['ProcessingTimeMs']?.toString() ?? '0ms',
summarySnippet: json['summary_snippet']?.toString() ?? json['summarySnippet']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'label': label,
'score': score,
'positiveProbability': positiveProbability,
'negativeProbability': negativeProbability,
'neutralProbability': neutralProbability,
'processingTimeMs': processingTimeMs,
'summarySnippet': summarySnippet,
};
}
@override
List<Object?> get props => [
label,
score,
positiveProbability,
negativeProbability,
neutralProbability,
processingTimeMs,
summarySnippet,
];
}
@@ -0,0 +1,99 @@
import 'package:equatable/equatable.dart';
import 'finbert_result_model.dart';
class NewsArticleModel extends Equatable {
final String id;
final String title;
final String author;
final String summary;
final String contentRaw;
final String sourceUrl;
final DateTime scrapedAt;
final DateTime publishedAt;
final String status;
// Flatted sentiment properties
final String sentiment;
final double sentimentScore;
final double confidence;
final FinbertResultModel? finbertResult;
const NewsArticleModel({
required this.id,
required this.title,
required this.author,
required this.summary,
required this.contentRaw,
required this.sourceUrl,
required this.scrapedAt,
required this.publishedAt,
required this.status,
required this.sentiment,
required this.sentimentScore,
required this.confidence,
this.finbertResult,
});
factory NewsArticleModel.fromJson(Map<String, dynamic> json) {
return NewsArticleModel(
id: json['id']?.toString() ?? json['Id']?.toString() ?? '',
title: json['title']?.toString() ?? json['Title']?.toString() ?? 'No Title',
author: json['author']?.toString() ?? json['Author']?.toString() ?? 'Unknown',
summary: json['summary']?.toString() ?? json['Summary']?.toString() ?? '',
contentRaw: json['contentRaw']?.toString() ?? json['ContentRaw']?.toString() ?? '',
sourceUrl: json['sourceUrl']?.toString() ?? json['SourceUrl']?.toString() ?? '',
scrapedAt: DateTime.tryParse(json['scrapedAt']?.toString() ?? json['ScrapedAt']?.toString() ?? '') ?? DateTime.now(),
publishedAt: DateTime.tryParse(json['publishedAt']?.toString() ?? json['PublishedAt']?.toString() ?? '') ?? DateTime.now(),
status: json['status']?.toString() ?? json['Status']?.toString() ?? 'Completed',
sentiment: json['sentiment']?.toString() ?? json['Sentiment']?.toString() ?? '',
sentimentScore: (json['sentimentScore'] ?? json['SentimentScore'] ?? 0.0).toDouble(),
confidence: (json['confidence'] ?? json['Confidence'] ?? 0.0).toDouble(),
finbertResult: (json['finbertResult'] != null || json['FinbertResult'] != null)
? FinbertResultModel.fromJson(json['finbertResult'] ?? json['FinbertResult'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'author': author,
'summary': summary,
'contentRaw': contentRaw,
'sourceUrl': sourceUrl,
'scrapedAt': scrapedAt.toIso8601String(),
'publishedAt': publishedAt.toIso8601String(),
'status': status,
'sentiment': sentiment,
'sentimentScore': sentimentScore,
'confidence': confidence,
'finbertResult': finbertResult != null ? {
'label': finbertResult!.label,
'score': finbertResult!.score,
'positiveProbability': finbertResult!.positiveProbability,
'negativeProbability': finbertResult!.negativeProbability,
'neutralProbability': finbertResult!.neutralProbability,
'processingTimeMs': finbertResult!.processingTimeMs,
} : null,
};
}
@override
List<Object?> get props => [
id,
title,
author,
summary,
contentRaw,
sourceUrl,
scrapedAt,
publishedAt,
status,
sentiment,
sentimentScore,
confidence,
finbertResult,
];
}
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:convert';
import 'package:signalr_core/signalr_core.dart';
import 'package:finlytic_app/core/network/api_client.dart';
import 'package:finlytic_app/features/news/models/news_article_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class NewsRepository {
final ApiClient apiClient;
final String backendUrl;
final FlutterSecureStorage secureStorage = const FlutterSecureStorage();
HubConnection? _hubConnection;
final _liveNewsController = StreamController<NewsArticleModel>.broadcast();
Stream<NewsArticleModel> get liveNewsStream => _liveNewsController.stream;
NewsRepository({required this.apiClient, required this.backendUrl});
Future<List<NewsArticleModel>> fetchNews({
int page = 1,
int pageSize = 20,
String? symbol,
String? isin,
String? date,
}) async {
try {
final Map<String, dynamic> queryParams = {
'page': page,
'pageSize': pageSize,
};
if (symbol != null && symbol.isNotEmpty) queryParams['symbol'] = symbol;
if (isin != null && isin.isNotEmpty) queryParams['isin'] = isin;
if (date != null && date.isNotEmpty) queryParams['date'] = date;
final response = await apiClient.get('/api/v1/news', queryParameters: queryParams);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => NewsArticleModel.fromJson(json)).toList();
}
return [];
} catch (e) {
print('Error fetching news: $e');
throw Exception('Failed to load news');
}
}
Future<void> connectToLiveFeed() async {
if (_hubConnection != null && _hubConnection!.state == HubConnectionState.connected) {
return;
}
try {
final token = await secureStorage.read(key: 'auth_token');
final hubUrl = '$backendUrl/hubs/news${token != null ? '?access_token=$token' : ''}';
_hubConnection = HubConnectionBuilder()
.withUrl(hubUrl, HttpConnectionOptions(
logging: (level, message) => print(message),
))
.withAutomaticReconnect()
.build();
_hubConnection!.on('ReceiveNewArticle', (arguments) {
if (arguments != null && arguments.isNotEmpty) {
try {
// SignalR might send it as a Map or raw JSON depending on .NET format
final dynamic rawPayload = arguments[0];
final Map<String, dynamic> jsonData = rawPayload is String
? jsonDecode(rawPayload)
: Map<String, dynamic>.from(rawPayload);
final article = NewsArticleModel.fromJson(jsonData);
_liveNewsController.add(article);
} catch (e) {
print('Error parsing live article: $e');
}
}
});
await _hubConnection!.start();
print('Connected to News Live Feed');
} catch (e) {
print('Error connecting to News Live Feed: $e');
}
}
Future<void> disconnectFromLiveFeed() async {
if (_hubConnection != null) {
await _hubConnection!.stop();
_hubConnection = null;
}
}
void dispose() {
_liveNewsController.close();
disconnectFromLiveFeed();
}
}
@@ -0,0 +1,210 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../widgets/news_card_item.dart';
import '../widgets/advanced_news_filter_bar.dart';
/// Paginated Infinite Scroll Daily News Feed screen with deduplication and strict chronological sorting.
class NewsFeedScreen extends StatefulWidget {
final ApiClient apiClient;
const NewsFeedScreen({super.key, required this.apiClient});
@override
State<NewsFeedScreen> createState() => _NewsFeedScreenState();
}
class _NewsFeedScreenState extends State<NewsFeedScreen> {
final ScrollController _scrollController = ScrollController();
final List<dynamic> _newsItems = [];
int _currentPage = 1;
static const int _pageSize = 15;
bool _isLoading = false;
bool _hasMore = true;
// Filter States
String? _searchQuery;
DateTime? _selectedDate;
String? _selectedIsin;
bool _hasSentimentOnly = false;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_loadNews(refresh: true);
_scrollController.addListener(() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
_loadNews();
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
DateTime _parseDateTime(dynamic val) {
if (val == null) return DateTime.fromMillisecondsSinceEpoch(0);
final str = val.toString().trim();
if (str.isEmpty) return DateTime.fromMillisecondsSinceEpoch(0);
try {
return DateTime.parse(str).toUtc();
} catch (_) {
return DateTime.fromMillisecondsSinceEpoch(0);
}
}
Future<void> _loadNews({bool refresh = false}) async {
if (_isLoading) return;
if (refresh) {
_currentPage = 1;
_hasMore = true;
_newsItems.clear();
}
if (!_hasMore) return;
setState(() => _isLoading = true);
try {
final queryParams = <String, dynamic>{
'page': _currentPage,
'pageSize': _pageSize,
};
if (_selectedDate != null) {
queryParams['date'] = _selectedDate!.toIso8601String().substring(0, 10);
}
if (_searchQuery != null && _searchQuery!.trim().isNotEmpty) {
queryParams['query'] = _searchQuery!.trim();
}
if (_selectedIsin != null && _selectedIsin!.trim().isNotEmpty) {
queryParams['isin'] = _selectedIsin!.trim();
}
if (_hasSentimentOnly) {
queryParams['hasSentiment'] = true;
}
final res = await widget.apiClient.get('/api/v1/news', queryParameters: queryParams);
if (res.statusCode == 200 && res.data != null && res.data is List) {
final List fetched = res.data as List;
setState(() {
// Deduplicate by ID
final existingIds = _newsItems.map((e) => e['id'] ?? e['Id']).where((id) => id != null).toSet();
for (final item in fetched) {
final id = item['id'] ?? item['Id'];
if (id == null || !existingIds.contains(id)) {
_newsItems.add(item);
if (id != null) existingIds.add(id);
}
}
// Re-sort strictly by publication timestamp descending (newest articles at the top)
_newsItems.sort((a, b) {
final dtA = _parseDateTime(a['publishedAt'] ?? a['PublishedAt'] ?? a['scrapedAt'] ?? a['ScrapedAt']);
final dtB = _parseDateTime(b['publishedAt'] ?? b['PublishedAt'] ?? b['scrapedAt'] ?? b['ScrapedAt']);
return dtB.compareTo(dtA);
});
_currentPage++;
if (fetched.length < _pageSize) {
_hasMore = false;
}
});
}
} catch (_) {
// Handle error visually if necessary, currently silent fallback
} finally {
setState(() => _isLoading = false);
}
}
void _onSearchChanged(String? val) {
_searchQuery = val;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
_loadNews(refresh: true);
});
}
void _onIsinChanged(String? val) {
_selectedIsin = val;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
_loadNews(refresh: true);
});
}
void _resetFilters() {
_debounceTimer?.cancel();
setState(() {
_searchQuery = null;
_selectedDate = null;
_selectedIsin = null;
_hasSentimentOnly = false;
});
_loadNews(refresh: true);
}
@override
Widget build(BuildContext context) {
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)),
const SizedBox(height: 12),
AdvancedNewsFilterBar(
searchQuery: _searchQuery,
selectedDate: _selectedDate,
selectedIsin: _selectedIsin,
hasSentimentOnly: _hasSentimentOnly,
onSearchChanged: _onSearchChanged,
onIsinChanged: _onIsinChanged,
onDateChanged: (val) {
setState(() => _selectedDate = val);
_loadNews(refresh: true);
},
onSentimentToggleChanged: (val) {
setState(() => _hasSentimentOnly = val);
_loadNews(refresh: true);
},
onResetFilters: _resetFilters,
),
const SizedBox(height: 16),
Expanded(
child: _newsItems.isEmpty && !_isLoading
? Center(
child: Text('Keine Nachrichten für diese Filterkriterien gefunden.', style: TextStyle(color: AppTheme.textMuted)),
)
: ListView.builder(
controller: _scrollController,
itemCount: _newsItems.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _newsItems.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: CircularProgressIndicator(color: AppTheme.primaryEmerald),
),
);
}
return NewsCardItem(item: _newsItems[index], apiClient: widget.apiClient);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
class AdvancedNewsFilterBar extends StatelessWidget {
final String? searchQuery;
final DateTime? selectedDate;
final String? selectedIsin;
final bool hasSentimentOnly;
final Function(String?) onSearchChanged;
final Function(DateTime?) onDateChanged;
final Function(String?) onIsinChanged;
final Function(bool) onSentimentToggleChanged;
final VoidCallback onResetFilters;
const AdvancedNewsFilterBar({
super.key,
required this.searchQuery,
required this.selectedDate,
required this.selectedIsin,
required this.hasSentimentOnly,
required this.onSearchChanged,
required this.onDateChanged,
required this.onIsinChanged,
required this.onSentimentToggleChanged,
required this.onResetFilters,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: TextEditingController(text: searchQuery)..selection = TextSelection.collapsed(offset: searchQuery?.length ?? 0),
onChanged: onSearchChanged,
style: const TextStyle(fontSize: 14, color: Colors.white),
decoration: InputDecoration(
hintText: 'Nachrichten durchsuchen...',
hintStyle: TextStyle(color: AppTheme.textMuted),
prefixIcon: Icon(Icons.search, color: AppTheme.textMuted, size: 20),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
filled: true,
fillColor: Colors.black.withValues(alpha: 0.2),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.primaryEmerald),
),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: TextField(
controller: TextEditingController(text: selectedIsin)..selection = TextSelection.collapsed(offset: selectedIsin?.length ?? 0),
onChanged: onIsinChanged,
style: const TextStyle(fontSize: 14, color: Colors.white),
decoration: InputDecoration(
hintText: 'Wertpapier ISIN...',
hintStyle: TextStyle(color: AppTheme.textMuted),
prefixIcon: Icon(Icons.business, color: AppTheme.textMuted, size: 20),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
filled: true,
fillColor: Colors.black.withValues(alpha: 0.2),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.glassBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: AppTheme.primaryEmerald),
),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.dark(
primary: AppTheme.primaryEmerald,
onPrimary: Colors.black,
surface: AppTheme.darkBackground,
onSurface: Colors.white,
),
),
child: child!,
);
},
);
if (date != null) {
onDateChanged(date);
}
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selectedDate != null ? AppTheme.primaryEmerald.withValues(alpha: 0.2) : Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.glassBorder),
),
child: Row(
children: [
Icon(Icons.calendar_today, size: 16, color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted),
const SizedBox(width: 8),
Text(
selectedDate != null ? DateFormat('dd.MM.yyyy').format(selectedDate!) : 'Datum wählen',
style: TextStyle(
fontSize: 13,
color: selectedDate != null ? AppTheme.primaryEmerald : AppTheme.textMuted,
fontWeight: selectedDate != null ? FontWeight.bold : FontWeight.normal,
),
),
if (selectedDate != null) ...[
const SizedBox(width: 4),
GestureDetector(
onTap: () => onDateChanged(null),
child: Icon(Icons.close, size: 16, color: AppTheme.primaryEmerald),
),
],
],
),
),
),
const SizedBox(width: 16),
FilterChip(
label: const Text('Nur Analysiert (KI)'),
selected: hasSentimentOnly,
onSelected: onSentimentToggleChanged,
backgroundColor: Colors.black.withValues(alpha: 0.2),
selectedColor: AppTheme.accentCyan.withValues(alpha: 0.2),
checkmarkColor: AppTheme.accentCyan,
side: BorderSide(color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.glassBorder),
labelStyle: TextStyle(
color: hasSentimentOnly ? AppTheme.accentCyan : AppTheme.textMuted,
fontSize: 13,
),
),
const Spacer(),
TextButton.icon(
onPressed: onResetFilters,
icon: Icon(Icons.refresh, size: 16, color: AppTheme.textMuted),
label: Text('Reset Filter', style: TextStyle(color: AppTheme.textMuted, fontSize: 13)),
),
],
),
],
),
);
}
}
@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/news_article_model.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/status_badge.dart';
/// Two-Tab Dialog for Article details and real FinBERT Sentiment Analysis.
class ArticleSentimentDialog extends StatefulWidget {
final NewsArticleModel articleData;
const ArticleSentimentDialog({
super.key,
required this.articleData,
});
@override
State<ArticleSentimentDialog> createState() => _ArticleSentimentDialogState();
}
class _ArticleSentimentDialogState extends State<ArticleSentimentDialog> with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
String? _findString(List<String> keys) {
final artMap = widget.articleData.toJson();
for (final k in keys) {
if (artMap.containsKey(k) && artMap[k] != null) {
final val = artMap[k].toString().trim();
if (val.isNotEmpty) return val;
}
}
return null;
}
void _openOriginalSource() async {
final urlStr = widget.articleData.sourceUrl;
if (urlStr.isNotEmpty) {
final uri = Uri.parse(urlStr);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
Map<String, dynamic>? get _realSentimentData {
if (widget.articleData.finbertResult != null) {
return {'finbert_result': widget.articleData.finbertResult!.toJson()};
}
return null;
}
bool get _isLoadingSentiment => false;
@override
Widget build(BuildContext context) {
final title = _findString(['Title', 'title']) ?? 'Nachrichtenartikel';
final author = _findString(['Author', 'author', 'Source', 'source']) ?? 'Finlytic News';
final summary = _findString(['Summary', 'summary']);
final sourceUrl = _findString(['SourceUrl', 'sourceUrl']);
final contentRaw = _findString(['ContentRaw', 'contentRaw', 'content', 'Text', 'text']);
final publishedAt = _findString(['PublishedAt', 'publishedAt', 'ScrapedAt', 'scrapedAt']) ?? '';
final status = _findString(['Status', 'status']) ?? 'Completed';
final rawSentiment = _findString(['sentiment', 'Sentiment', 'sentimentLabel', 'SentimentLabel']);
final articleMap = widget.articleData.toJson();
final Map<String, dynamic> articleObj = articleMap.containsKey('article') && articleMap['article'] is Map
? Map<String, dynamic>.from(articleMap['article'])
: articleMap;
final matchedAssetsRaw = articleObj['MatchedAssets'] ?? articleObj['matchedAssets'] ?? articleMap['MatchedAssets'] ?? articleMap['matchedAssets'];
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
final double compoundScore = widget.articleData.finbertResult?.score ?? widget.articleData.sentimentScore;
final double confidenceScore = widget.articleData.confidence;
final String label = (widget.articleData.finbertResult?.label ?? widget.articleData.sentiment).toString().toUpperCase();
final double posRatio = widget.articleData.finbertResult?.positiveProbability ?? 0.0;
final double neuRatio = widget.articleData.finbertResult?.neutralProbability ?? 0.0;
final double negRatio = widget.articleData.finbertResult?.negativeProbability ?? 0.0;
final String aiText = widget.articleData.finbertResult?.summarySnippet ?? summary ?? 'FinBERT Sentiment-Analyse verarbeitet.';
final Widget listBadge = rawSentiment != null
? StatusBadge.sentiment(rawSentiment.toUpperCase(), score: compoundScore)
: StatusBadge(
label: status.toUpperCase(),
color: status.toLowerCase().contains('analyz') || status.toLowerCase().contains('klassifi')
? AppTheme.primaryEmerald
: AppTheme.accentCyan,
);
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
child: Container(
width: 700,
height: 620,
padding: const EdgeInsets.all(20),
child: Column(
children: [
TabBar(
controller: _tabController,
indicatorColor: AppTheme.primaryEmerald,
labelColor: AppTheme.primaryEmerald,
unselectedLabelColor: AppTheme.textMuted,
tabs: const [
Tab(icon: Icon(Icons.article_outlined), text: 'Artikel & Volltext'),
Tab(icon: Icon(Icons.psychology_outlined), text: 'Sentiment-Analyse (FinBERT)'),
],
),
const SizedBox(height: 14),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: Artikel & Volltext
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.3)),
),
if (sourceUrl != null && sourceUrl.isNotEmpty)
IconButton(
icon: Icon(Icons.open_in_new, color: AppTheme.primaryEmerald, size: 22),
tooltip: 'Originalquelle lesen',
onPressed: _openOriginalSource,
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'$author ${publishedAt.isNotEmpty ? "$publishedAt" : ""}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
),
listBadge,
],
),
if (matchedAssets.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Verknüpfte Wertpapiere (Matched Assets):', style: TextStyle(color: AppTheme.textMuted, fontSize: 11, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: matchedAssets.map((a) {
final name = a['Name'] ?? a['name'] ?? a['Isin'] ?? a['isin'] ?? 'Asset';
final isin = a['Isin'] ?? a['isin'] ?? '';
return Chip(
label: Text(
isin.toString().isNotEmpty && name.toString() != isin.toString()
? '${name.toString()} ($isin)'
: name.toString(),
style: TextStyle(fontSize: 11, color: AppTheme.accentCyan),
),
backgroundColor: AppTheme.glassSurface,
side: BorderSide(color: AppTheme.glassBorder),
padding: EdgeInsets.zero,
);
}).toList(),
),
],
Divider(height: 24, color: AppTheme.glassBorder),
if (summary != null && summary.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Zusammenfassung:', style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.accentCyan, fontSize: 12)),
const SizedBox(height: 4),
Text(summary, style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textPrimary)),
],
),
),
const SizedBox(height: 16),
],
const Text('Vollständiger Artikeltext:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
Text(
contentRaw ?? summary ?? 'Kein Volltext verfügbar.',
style: TextStyle(fontSize: 13.5, height: 1.6, color: AppTheme.textSecondary),
),
],
),
),
// Tab 2: Real Sentiment Analysis (FinBERT)
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('FinBERT KI Klassifizierung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (_realSentimentData != null) StatusBadge.sentiment(label, score: compoundScore),
],
),
const SizedBox(height: 16),
if (_isLoadingSentiment)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryEmerald, strokeWidth: 2),
),
)
else if (_realSentimentData == null)
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.info_outline, color: AppTheme.accentCyan, size: 44),
const SizedBox(height: 12),
const Text(
'Keine Sentiment-Analyse vorhanden',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Für diesen Artikel liegt aktuell noch keine FinBERT-Sentiment-Analyse vor.',
style: TextStyle(fontSize: 13, color: AppTheme.textMuted, height: 1.4),
textAlign: TextAlign.center,
),
],
),
)
else ...[
Row(
children: [
SentimentMetricCard(
title: 'Compound Score',
valueText: '${compoundScore > 0 ? "+" : ""}${compoundScore.toStringAsFixed(2)}',
tooltipText: 'Der Compound-Score misst die aggregierte Gesamtausrichtung der Nachricht auf einer Skala von -1.00 (sehr negativ) bis +1.00 (sehr positiv). Werte ab +0.15 gelten als positiv.',
accentColor: compoundScore > 0.15
? AppTheme.primaryEmerald
: (compoundScore < -0.15 ? AppTheme.accentRed : AppTheme.accentCyan),
),
const SizedBox(width: 12),
SentimentMetricCard(
title: 'KI-Confidence',
valueText: '${(confidenceScore * 100).toInt()}%',
tooltipText: 'Die Confidence gibt die statistische Wahrscheinlichkeit (0% bis 100%) an, mit welcher die FinBERT KI ihre Stimmungszuordnung berechnet hat.',
accentColor: AppTheme.accentCyan,
progressValue: confidenceScore,
),
],
),
const SizedBox(height: 16),
Text('Echte FinBERT Softmax-Verteilung:', style: TextStyle(color: AppTheme.textMuted, fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
ArticleSentimentMeterBar(label: 'Positiv', ratio: posRatio, color: AppTheme.primaryEmerald),
const SizedBox(height: 6),
ArticleSentimentMeterBar(label: 'Neutral', ratio: neuRatio, color: AppTheme.accentCyan),
const SizedBox(height: 6),
ArticleSentimentMeterBar(label: 'Negativ', ratio: negRatio, color: AppTheme.accentRed),
Divider(height: 24, color: AppTheme.glassBorder),
const Text('KI-Zusammenfassung & Auswirkung:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text(
aiText,
style: TextStyle(fontSize: 13, height: 1.4, color: AppTheme.textSecondary),
),
],
],
),
),
],
),
),
],
),
),
);
}
}
/// Extracted metric card component with interactive info explanation tooltips.
class SentimentMetricCard extends StatelessWidget {
final String title;
final String valueText;
final String tooltipText;
final Color accentColor;
final double? progressValue;
const SentimentMetricCard({
super.key,
required this.title,
required this.valueText,
required this.tooltipText,
required this.accentColor,
this.progressValue,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.glassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: TextStyle(fontSize: 12, color: AppTheme.textMuted, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
Tooltip(
message: tooltipText,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: const Color(0xFF1E2130),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
textStyle: const TextStyle(color: Colors.white, fontSize: 12, height: 1.3),
child: Icon(Icons.info_outline, size: 16, color: AppTheme.accentCyan),
),
],
),
const SizedBox(height: 6),
Text(
valueText,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: accentColor),
),
if (progressValue != null) ...[
const SizedBox(height: 6),
LinearProgressIndicator(
value: progressValue!.clamp(0.0, 1.0),
backgroundColor: AppTheme.glassBorder,
color: accentColor,
minHeight: 6,
),
],
],
),
),
);
}
}
/// Extracted sub-component for displaying sentiment probability bars.
class ArticleSentimentMeterBar extends StatelessWidget {
final String label;
final double ratio;
final Color color;
const ArticleSentimentMeterBar({
super.key,
required this.label,
required this.ratio,
required this.color,
});
@override
Widget build(BuildContext context) {
final clampedRatio = ratio.clamp(0.0, 1.0);
return Row(
children: [
SizedBox(width: 60, child: Text(label, style: const TextStyle(fontSize: 12))),
Expanded(
child: LinearProgressIndicator(
value: clampedRatio,
backgroundColor: AppTheme.glassSurface,
color: color,
minHeight: 10,
),
),
const SizedBox(width: 10),
Text('${(clampedRatio * 100).toInt()}%', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)),
],
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/time_utils.dart';
import '../../../core/widgets/glass_container.dart';
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.
class NewsCardItem extends StatelessWidget {
final dynamic item;
final ApiClient apiClient;
const NewsCardItem({
super.key,
required this.item,
required this.apiClient,
});
@override
Widget build(BuildContext context) {
final Map<String, dynamic> article = item is NewsArticleModel
? (item as NewsArticleModel).toJson()
: (item is Map && item.containsKey('article') && item['article'] is Map
? Map<String, dynamic>.from(item['article'])
: (item is Map ? Map<String, dynamic>.from(item) : {}));
final Map<String, dynamic>? sentimentObj = (item is Map && item.containsKey('sentiment') && item['sentiment'] is Map)
? Map<String, dynamic>.from(item['sentiment'])
: null;
final title = article['title']?.toString() ?? article['Title']?.toString() ?? 'Nachrichtenartikel';
final summary = article['summary']?.toString() ?? article['Summary']?.toString() ?? article['contentRaw']?.toString() ?? '';
final author = article['author']?.toString() ?? article['Author']?.toString() ?? article['source']?.toString() ?? 'Finlytic News';
final rawPubTime = article['publishedAt']?.toString() ?? article['PublishedAt']?.toString() ?? article['scrapedAt']?.toString();
final formattedTime = TimeUtils.formatRelativeTime(rawPubTime);
final pubTime = formattedTime.isNotEmpty ? formattedTime : 'Heute';
final rawStatus = article['status']?.toString() ?? article['Status']?.toString() ?? 'Completed';
final String? sentimentLabel = sentimentObj?['label']?.toString() ?? article['sentiment']?.toString();
final double? score = ((sentimentObj?['compoundScore'] ?? sentimentObj?['compound_score'] ?? article['sentimentScore']) as num?)?.toDouble();
final matchedAssetsRaw = article['MatchedAssets'] ?? article['matchedAssets'];
final matchedAssets = matchedAssetsRaw is List ? matchedAssetsRaw : [];
return GlassContainer(
margin: const EdgeInsets.only(bottom: 12),
onTap: () {
final NewsArticleModel articleModel;
if (item is NewsArticleModel) {
articleModel = item;
} else if (item is Map<String, dynamic>) {
articleModel = NewsArticleModel.fromJson(item);
} else if (item is Map) {
articleModel = NewsArticleModel.fromJson(Map<String, dynamic>.from(item));
} else {
articleModel = NewsArticleModel(
id: '',
title: title,
author: author,
summary: summary,
contentRaw: '',
sourceUrl: '',
scrapedAt: DateTime.now(),
publishedAt: DateTime.now(),
status: rawStatus,
sentiment: sentimentLabel ?? 'NEUTRAL',
sentimentScore: score ?? 0.0,
confidence: 0.0,
);
}
showDialog(
context: context,
builder: (_) => ArticleSentimentDialog(articleData: articleModel),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
),
const SizedBox(width: 8),
//badgeWidget,
],
),
const SizedBox(height: 8),
Text(summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 10),
Text('$author$pubTime', style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
if (matchedAssets.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: matchedAssets.map((assetItem) {
final assetSymbol = assetItem['Name']?.toString() ?? assetItem['name']?.toString() ?? assetItem['Isin']?.toString() ?? assetItem['isin']?.toString() ?? 'ASSET';
return ActionChip(
label: Text(assetSymbol, style: TextStyle(fontSize: 10, color: AppTheme.accentCyan)),
backgroundColor: AppTheme.glassSurface,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AssetDetailScreen(
symbol: assetSymbol,
apiClient: apiClient,
),
),
);
},
);
}).toList(),
),
],
],
),
);
}
}