feat(asset_detail): modular fundamentals sections, executive salaries and logo resolution
This commit is contained in:
@@ -3,14 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/theme/app_theme.dart';
|
||||
import '../../../../core/widgets/glass_container.dart';
|
||||
import '../../../../core/widgets/shimmer_loading.dart';
|
||||
import '../../../../core/widgets/status_badge.dart';
|
||||
import '../../bloc/fundamentals/asset_fundamentals_bloc.dart';
|
||||
import '../../bloc/fundamentals/asset_fundamentals_event.dart';
|
||||
import '../../bloc/fundamentals/asset_fundamentals_state.dart';
|
||||
import '../../models/fundamental_data_model.dart';
|
||||
import '../../utils/metric_explanations.dart';
|
||||
import '../../widgets/fundamentals/analyst_price_target_card.dart';
|
||||
import '../../widgets/fundamentals/fundamental_category_panels.dart';
|
||||
import '../../widgets/fundamentals/company_profile_section.dart';
|
||||
|
||||
class FundamentalsTab extends StatefulWidget {
|
||||
class FundamentalsTab extends StatelessWidget {
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
final bool isEmbedded;
|
||||
@@ -22,22 +22,26 @@ class FundamentalsTab extends StatefulWidget {
|
||||
required this.isin,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
||||
}
|
||||
|
||||
class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
String _sym = '\$';
|
||||
String _curCode = 'USD';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
String _getCurrencySymbol(String? ticker) {
|
||||
if (ticker == null || ticker.isEmpty) return '€';
|
||||
final t = ticker.toUpperCase();
|
||||
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
|
||||
return '€';
|
||||
}
|
||||
if (t.endsWith('.L')) return '£';
|
||||
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CA\$';
|
||||
return '\$';
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
String _getCurrencyCode(String? ticker) {
|
||||
if (ticker == null || ticker.isEmpty) return 'EUR';
|
||||
final t = ticker.toUpperCase();
|
||||
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.VI') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MC') || t.endsWith('.MI')) {
|
||||
return 'EUR';
|
||||
}
|
||||
if (t.endsWith('.L')) return 'GBP';
|
||||
if (t.endsWith('.TO') || t.endsWith('.V')) return 'CAD';
|
||||
return 'USD';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -60,7 +64,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
Text('Fehler beim Laden der Fundamentaldaten: ${state.message}', style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
@@ -70,89 +74,68 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AssetFundamentalsLoaded) {
|
||||
final data = state.data;
|
||||
if (data != null) {
|
||||
_sym = _getCurrencySymbol(data.ticker);
|
||||
_curCode = _getCurrencyCode(data.ticker);
|
||||
}
|
||||
if (data == null) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
if (state is AssetFundamentalsLoaded && state.data != null) {
|
||||
final data = state.data!;
|
||||
final sym = _getCurrencySymbol(data.ticker);
|
||||
final curCode = _getCurrencyCode(data.ticker);
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. Analyst Forecasts & Price Targets Header Card
|
||||
_buildPriceTargetCard(data),
|
||||
AnalystPriceTargetCard(data: data, currencySymbol: sym),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
||||
_buildCategoryPanels(data),
|
||||
FundamentalCategoryPanels(data: data, currencySymbol: sym, currencyCode: curCode),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 3. Company Description & Detailed Executive Board
|
||||
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
||||
const SizedBox(height: 12),
|
||||
_buildProfileSection(data),
|
||||
CompanyProfileSection(data: data, currencySymbol: sym),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildEmptyState();
|
||||
return _buildEmptyState(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceTargetCard(FundamentalDataModel data) {
|
||||
final rating = data.consensusRating ?? 'N/A';
|
||||
final targetMean = data.priceTargetMean;
|
||||
final targetLow = data.priceTargetLow;
|
||||
final targetHigh = data.priceTargetHigh;
|
||||
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.trending_up, color: AppTheme.primaryEmerald, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Analysten-Konsens & Kursziele', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
StatusBadge(label: rating.toUpperCase(), color: AppTheme.primaryEmerald),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildTargetStat('Mindestkursziel', _fmtCurrency(targetLow), AppTheme.accentRed),
|
||||
_buildTargetStat('Konsens-Ziel (Durchschnitt)', _fmtCurrency(targetMean), AppTheme.primaryEmerald),
|
||||
_buildTargetStat('Höchstkursziel', _fmtCurrency(targetHigh), AppTheme.accentCyan),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Widget _buildSectionHeader(String title, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTargetStat(String title, String val, Color col) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
const SizedBox(height: 4),
|
||||
Text(val, style: TextStyle(color: col, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.analytics_outlined, color: AppTheme.textMuted, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(isin, ticker: symbol, forceRefresh: true)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Aktualisieren'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,16 +171,13 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||
physics: isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Price Target Card Shimmer
|
||||
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 3 Category Panels Shimmer
|
||||
if (isDesktop)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -234,9 +214,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
panelShimmer(),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
// Profile Section Shimmer
|
||||
const ShimmerLoading(width: 220, height: 20, borderRadius: 6),
|
||||
const SizedBox(height: 12),
|
||||
const ShimmerLoading(width: double.infinity, height: 140, borderRadius: 16),
|
||||
@@ -244,454 +222,4 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileSection(FundamentalDataModel data) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (data.sector != null || data.industry != null || data.country != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
if (data.sector != null) ...[
|
||||
_buildProfileBadge(data.sector!, Icons.category_outlined),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (data.country != null)
|
||||
_buildProfileBadge(data.country!, Icons.place_outlined),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Text(
|
||||
data.businessSummary != null && data.businessSummary!.isNotEmpty
|
||||
? data.businessSummary!
|
||||
: 'Keine Beschreibung für dieses Asset verfügbar.',
|
||||
style: const TextStyle(color: Colors.white70, height: 1.5, fontSize: 13),
|
||||
),
|
||||
if (data.employees != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.people_outline, size: 16, color: AppTheme.textMuted),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Mitarbeiter: ${data.employees}',
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (data.executives.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text('Führungskräfte (Board)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
...data.executives.take(5).map((e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: GlassContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.person_outline, color: AppTheme.primaryEmerald, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(e.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)),
|
||||
Text(e.title, style: TextStyle(color: AppTheme.textMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (e.compensation != null && e.compensation! > 0)
|
||||
Text(
|
||||
_formatNumber(e.compensation),
|
||||
style: TextStyle(color: AppTheme.accentCyan, fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileBadge(String label, IconData icon) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: GlassContainer(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.insert_chart_outlined, color: AppTheme.textMuted, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Keine Fundamentaldaten verfügbar.', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Für dieses Asset wurden noch keine Bilanz- oder Bewertungskennzahlen erfasst.', style: TextStyle(color: AppTheme.textMuted, fontSize: 12), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Daten von Backend abrufen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: AppTheme.primaryEmerald, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryPanels(FundamentalDataModel data) {
|
||||
final valuationItems = [
|
||||
_MetricRowItem('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
|
||||
_MetricRowItem('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
|
||||
_MetricRowItem('PEG Ratio', _fmtMultiple(data.pegRatio)),
|
||||
_MetricRowItem('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
|
||||
_MetricRowItem('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
|
||||
_MetricRowItem('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
|
||||
_MetricRowItem('EV / Sales', _fmtMultiple(data.evToRevenue)),
|
||||
_MetricRowItem('Enterprise Value', _formatNumber(data.enterpriseValue)),
|
||||
_MetricRowItem('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
|
||||
_MetricRowItem('Gewinn je Aktie (EPS)', _fmtCurrency(data.dilutedEps)),
|
||||
_MetricRowItem('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
|
||||
_MetricRowItem('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
|
||||
];
|
||||
|
||||
final profitabilityItems = [
|
||||
_MetricRowItem('Umsatzerlöse (Revenue)', _formatNumber(data.totalRevenue)),
|
||||
_MetricRowItem('Umsatzwachstum (YoY)', _fmtPercent(data.revenueGrowthYoY)),
|
||||
_MetricRowItem('Bruttogewinn', _formatNumber(data.grossProfit)),
|
||||
_MetricRowItem('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
|
||||
_MetricRowItem('EBITDA', _formatNumber(data.ebitda)),
|
||||
_MetricRowItem('Operative Marge', _fmtPercent(data.operatingMargin)),
|
||||
_MetricRowItem('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
|
||||
_MetricRowItem('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
|
||||
_MetricRowItem('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
|
||||
_MetricRowItem('Verschuldungsgrad (D/E)', _fmtDebtToEquity(data.debtToEquity)),
|
||||
_MetricRowItem('Current Ratio', _fmtMultiple(data.currentRatio)),
|
||||
_MetricRowItem('Liquide Mittel (Cash)', _formatNumber(data.totalCash)),
|
||||
_MetricRowItem('Gesamtverschuldung (Debt)', _formatNumber(data.totalDebt)),
|
||||
_MetricRowItem('Operativer Cashflow', _formatNumber(data.operatingCashFlow)),
|
||||
_MetricRowItem('Free Cashflow', _formatNumber(data.freeCashFlow)),
|
||||
];
|
||||
|
||||
final dividendItems = [
|
||||
_MetricRowItem('Dividendenrendite', _fmtPercent(data.dividendYield)),
|
||||
_MetricRowItem('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
|
||||
_MetricRowItem('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
|
||||
_MetricRowItem('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
|
||||
_MetricRowItem('Konsens-Rating', data.consensusRating != null ? data.consensusRating!.toUpperCase() : 'N/A'),
|
||||
_MetricRowItem('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
|
||||
_MetricRowItem('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
|
||||
_MetricRowItem('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
|
||||
];
|
||||
|
||||
final panel1 = _buildCategoryPanel(
|
||||
title: 'Bewertungskennzahlen & Multiples',
|
||||
icon: Icons.analytics_outlined,
|
||||
items: valuationItems,
|
||||
);
|
||||
|
||||
final panel2 = _buildCategoryPanel(
|
||||
title: 'Rentabilität & Finanzen',
|
||||
icon: Icons.account_balance_outlined,
|
||||
items: profitabilityItems,
|
||||
);
|
||||
|
||||
final panel3 = _buildCategoryPanel(
|
||||
title: 'Dividenden & Termine',
|
||||
icon: Icons.pie_chart_outline,
|
||||
items: dividendItems,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 1050) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: panel1),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: panel2),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: panel3),
|
||||
],
|
||||
);
|
||||
} else if (constraints.maxWidth >= 680) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: panel1),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: panel2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
panel3,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
panel1,
|
||||
const SizedBox(height: 12),
|
||||
panel2,
|
||||
const SizedBox(height: 12),
|
||||
panel3,
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryPanel({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<_MetricRowItem> items,
|
||||
}) {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryEmerald.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(icon, color: AppTheme.primaryEmerald, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(color: Colors.white10, height: 1),
|
||||
const SizedBox(height: 4),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final item = entry.value;
|
||||
final isEven = idx % 2 == 0;
|
||||
return _buildMetricListRow(item.label, item.value, isEven: isEven, valueColor: item.valueColor);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricListRow(String label, String value, {bool isEven = false, Color? valueColor}) {
|
||||
return InkWell(
|
||||
onTap: () => MetricExplanations.show(context, label),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.info_outline, size: 11, color: AppTheme.textMuted.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: valueColor ?? (value == 'N/A' ? AppTheme.textMuted : Colors.white),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmtMultiple(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
return n != null ? '${n.toStringAsFixed(2)}x' : 'N/A';
|
||||
}
|
||||
|
||||
String _fmtDays(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
return n != null ? '${n.toStringAsFixed(1)} Tage' : 'N/A';
|
||||
}
|
||||
|
||||
String _fmtDebtToEquity(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
if (n == null) return 'N/A';
|
||||
// Yahoo liefert D/E als Prozentwert (z. B. 145.23 = 145.23% oder Faktor 1.45x)
|
||||
if (n > 5) {
|
||||
return '${(n / 100).toStringAsFixed(2)}x (${n.toStringAsFixed(1)} %)';
|
||||
}
|
||||
return '${n.toStringAsFixed(2)}x (${(n * 100).toStringAsFixed(1)} %)';
|
||||
}
|
||||
|
||||
String _fmtPercent(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
if (n == null) return 'N/A';
|
||||
// Yahoo liefert Margen/Renditen als Dezimalzahl (z. B. 0.25 = 25%, 1.2 = 120%)
|
||||
// Wenn |n| <= 2.5 ist, handelt es sich um eine Dezimalquote -> mit 100 multiplizieren
|
||||
final p = n.abs() <= 2.5 ? n * 100 : n;
|
||||
return '${p.toStringAsFixed(2)} %';
|
||||
}
|
||||
|
||||
String _fmtCurrency(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final n = (val is num) ? val.toDouble() : double.tryParse(val.toString());
|
||||
if (n == null || n == 0) return 'N/A';
|
||||
return '$_sym${n.toStringAsFixed(2)}';
|
||||
}
|
||||
|
||||
String _fmtDate(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final dt = DateTime.tryParse(val.toString());
|
||||
return dt != null ? '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}' : val.toString();
|
||||
}
|
||||
|
||||
String _formatNumber(dynamic val) {
|
||||
if (val == null) return 'N/A';
|
||||
final num? n = val is num ? val : num.tryParse(val.toString());
|
||||
if (n == null) return val.toString();
|
||||
|
||||
final isNegative = n < 0;
|
||||
final absVal = n.abs();
|
||||
final prefix = isNegative ? '-$_sym' : _sym;
|
||||
|
||||
if (absVal >= 1e12) {
|
||||
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bio.';
|
||||
} else if (absVal >= 1e9) {
|
||||
return '$prefix${(absVal / 1e9).toStringAsFixed(2)} Mrd.';
|
||||
} else if (absVal >= 1e6) {
|
||||
return '$prefix${(absVal / 1e6).toStringAsFixed(2)} Mio.';
|
||||
} else if (absVal >= 1e3) {
|
||||
return '$prefix${(absVal / 1e3).toStringAsFixed(1)} Tsd.';
|
||||
} else {
|
||||
return '$prefix${absVal.toStringAsFixed(2)}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Leitet das Währungssymbol vom Ticker-Suffix ab.
|
||||
String _getCurrencySymbol(String? ticker) {
|
||||
if (ticker == null || ticker.isEmpty) return '\$';
|
||||
final t = ticker.toUpperCase();
|
||||
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') ||
|
||||
t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') ||
|
||||
t.endsWith('.BE') || t.endsWith('.SG') ||
|
||||
t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') ||
|
||||
t.endsWith('.MC')) return '€';
|
||||
if (t.endsWith('.L')) return '£';
|
||||
if (t.endsWith('.SW')) return 'CHF ';
|
||||
if (t.endsWith('.TO')) return 'CA\$';
|
||||
if (t.endsWith('.AX')) return 'A\$';
|
||||
if (t.endsWith('.T')) return '¥';
|
||||
if (t.endsWith('.HK')) return 'HK\$';
|
||||
return '\$';
|
||||
}
|
||||
|
||||
/// Leitet den Währungscode vom Ticker-Suffix ab.
|
||||
String _getCurrencyCode(String? ticker) {
|
||||
if (ticker == null || ticker.isEmpty) return 'USD';
|
||||
final t = ticker.toUpperCase();
|
||||
if (t.endsWith('.DE') || t.endsWith('.F') || t.endsWith('.STU') ||
|
||||
t.endsWith('.MU') || t.endsWith('.HM') || t.endsWith('.DU') ||
|
||||
t.endsWith('.BE') || t.endsWith('.SG') ||
|
||||
t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI') ||
|
||||
t.endsWith('.MC')) return 'EUR';
|
||||
if (t.endsWith('.L')) return 'GBP';
|
||||
if (t.endsWith('.SW')) return 'CHF';
|
||||
if (t.endsWith('.TO')) return 'CAD';
|
||||
if (t.endsWith('.AX')) return 'AUD';
|
||||
if (t.endsWith('.T')) return 'JPY';
|
||||
if (t.endsWith('.HK')) return 'HKD';
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricRowItem {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
const _MetricRowItem(this.label, this.value, {this.valueColor});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user