feat(asset_detail): modular fundamentals sections, executive salaries and logo resolution

This commit is contained in:
2026-08-15 01:03:30 +02:00
parent 15f8f7896e
commit 1ccb6b613f
21 changed files with 2011 additions and 2081 deletions
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../models/fundamental_data_model.dart';
class AnalystPriceTargetCard extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const AnalystPriceTargetCard({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
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 _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)),
],
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
class CompanyProfileSection extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
const CompanyProfileSection({
super.key,
required this.data,
this.currencySymbol = '\$',
});
String _fmtCompensation(double? val) {
if (val == null || val <= 0) return '---';
if (val >= 1e6) return '$currencySymbol${(val / 1e6).toStringAsFixed(2)}M';
if (val >= 1e3) return '$currencySymbol${(val / 1e3).toStringAsFixed(0)}K';
return '$currencySymbol${val.toStringAsFixed(0)}';
}
String _formatExecutivePayment(CompanyExecutiveModel exec) {
if (exec.compensation != null && exec.compensation! > 0) {
return _fmtCompensation(exec.compensation);
}
if (exec.payment != null && exec.payment!.isNotEmpty) {
final p = exec.payment!.trim();
if (p.startsWith(currencySymbol) || p.startsWith('') || p.startsWith(r'$')) {
return p;
}
final numeric = double.tryParse(p);
if (numeric != null && numeric > 0) {
return _fmtCompensation(numeric);
}
return '$currencySymbol$p';
}
return '---';
}
@override
Widget build(BuildContext context) {
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(
'Vollzeitbeschäftigte: ${data.employees}',
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
),
],
),
],
],
),
),
if (data.executives.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Führungskräfte & Vorstand',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Column(
children: [
for (int i = 0; i < data.executives.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.executives[i].name,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 13),
),
const SizedBox(height: 2),
Text(
data.executives[i].title,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Builder(
builder: (context) {
final payStr = _formatExecutivePayment(data.executives[i]);
if (payStr == '---') return const SizedBox.shrink();
return Text(
payStr,
style: TextStyle(color: AppTheme.primaryEmerald, fontWeight: FontWeight.bold, fontSize: 12),
);
},
),
],
),
),
if (i < data.executives.length - 1) const Divider(color: Colors.white10, height: 1),
],
],
),
),
],
],
);
}
Widget _buildProfileBadge(String text, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppTheme.glassSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.glassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppTheme.primaryEmerald),
const SizedBox(width: 6),
Text(text, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500)),
],
),
);
}
}
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.dart';
import '../../models/fundamental_data_model.dart';
import '../../utils/metric_explanations.dart';
class _MetricRowItem {
final String label;
final String value;
const _MetricRowItem(this.label, this.value);
}
class FundamentalCategoryPanels extends StatelessWidget {
final FundamentalDataModel data;
final String currencySymbol;
final String currencyCode;
const FundamentalCategoryPanels({
super.key,
required this.data,
this.currencySymbol = '\$',
this.currencyCode = 'USD',
});
String _formatNumber(double? number) {
if (number == null) return 'N/A';
final abs = number.abs();
final sign = number < 0 ? '-' : '';
if (abs >= 1e12) return '$sign$currencySymbol${(abs / 1e12).toStringAsFixed(2)} Tsd. Mrd. $currencyCode';
if (abs >= 1e9) return '$sign$currencySymbol${(abs / 1e9).toStringAsFixed(2)} Mrd. $currencyCode';
if (abs >= 1e6) return '$sign$currencySymbol${(abs / 1e6).toStringAsFixed(2)} Mio. $currencyCode';
return '$sign$currencySymbol${NumberFormat("#,##0.00", "de_DE").format(abs)} $currencyCode';
}
String _fmtCurrency(double? val) {
if (val == null) return 'N/A';
return '$currencySymbol${val.toStringAsFixed(2)}';
}
String _fmtMultiple(double? val) {
if (val == null) return 'N/A';
return '${val.toStringAsFixed(2)}x';
}
String _fmtPercent(double? val) {
if (val == null) return 'N/A';
final p = (val.abs() <= 1.0 && val != 0.0) ? val * 100.0 : val;
return '${p.toStringAsFixed(2)}%';
}
String _fmtDebtToEquity(double? val) {
if (val == null) return 'N/A';
final p = val > 10.0 ? val : val * 100.0;
return '${p.toStringAsFixed(1)}%';
}
String _fmtDate(String? raw) {
if (raw == null || raw.isEmpty) return 'N/A';
final dt = DateTime.tryParse(raw);
if (dt == null) return raw;
return DateFormat('dd.MM.yyyy').format(dt);
}
@override
Widget build(BuildContext context) {
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(
context: context,
title: 'Bewertungskennzahlen & Multiples',
icon: Icons.analytics_outlined,
items: valuationItems,
);
final panel2 = _buildCategoryPanel(
context: context,
title: 'Rentabilität & Finanzen',
icon: Icons.account_balance_outlined,
items: profitabilityItems,
);
final panel3 = _buildCategoryPanel(
context: context,
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 BuildContext context,
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),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 10),
const Divider(color: Colors.white10, height: 1),
const SizedBox(height: 4),
for (int i = 0; i < items.length; i++) ...[
_buildMetricTile(context, items[i].label, items[i].value, isEven: i.isEven),
],
],
),
);
}
Widget _buildMetricTile(BuildContext context, String label, String value, {bool isEven = false}) {
final hasExplanation = MetricExplanations.hasExplanation(label);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: isEven ? Colors.white.withValues(alpha: 0.02) : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
if (hasExplanation) ...[
const SizedBox(width: 4),
InkWell(
onTap: () => MetricExplanations.showModal(context, label),
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 12),
),
],
),
);
}
}