feat(App): update Finlytic Flutter app UI and blocs
This commit is contained in:
@@ -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