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