feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user