feat(App): update Finlytic Flutter app UI and blocs

This commit is contained in:
2026-08-09 21:01:46 +02:00
parent e7427b7464
commit a708d2977c
591 changed files with 1095105 additions and 0 deletions
@@ -0,0 +1,630 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/fundamental_data_model.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/glass_container.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 '../../utils/metric_explanations.dart';
class FundamentalsTab extends StatefulWidget {
final String symbol;
const FundamentalsTab({super.key, required this.symbol});
@override
State<FundamentalsTab> createState() => _FundamentalsTabState();
}
class _FundamentalsTabState extends State<FundamentalsTab> {
String _selectedPeriodType = 'Annual'; // 'Annual' or 'Quarterly'
String _selectedStatementType = 'Income'; // 'Income', 'Balance', 'CashFlow'
@override
void initState() {
super.initState();
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: false));
}
@override
void didUpdateWidget(covariant FundamentalsTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.symbol != widget.symbol) {
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(widget.symbol, forceRefresh: false));
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
builder: (context, state) {
if (state is AssetFundamentalsLoading) {
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
}
if (state is AssetFundamentalsError) {
return Center(
child: GlassContainer(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: AppTheme.accentRed, size: 48),
const SizedBox(height: 12),
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.symbol, forceRefresh: true)),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
);
}
if (state is AssetFundamentalsLoaded) {
final data = state.data;
if (data == null) {
return _buildEmptyState();
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Analyst Forecasts & Price Targets Header Card
_buildPriceTargetCard(data),
const SizedBox(height: 20),
// 2. Valuation Multiples & Ratios
_buildSectionHeader('Bewertungskennzahlen & Multiples', Icons.analytics_outlined),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('KGV (Trailing P/E)', _fmtMultiple(data.peRatioTrailing)),
_buildMetricCard('KGV (Forward P/E)', _fmtMultiple(data.peRatioForward)),
_buildMetricCard('PEG Ratio', _fmtMultiple(data.pegRatio)),
_buildMetricCard('KBV (P/B Ratio)', _fmtMultiple(data.pbRatio)),
_buildMetricCard('KUV (P/S Ratio)', _fmtMultiple(data.psRatio)),
_buildMetricCard('EV / EBITDA', _fmtMultiple(data.evToEbitda)),
_buildMetricCard('EV / Sales', _fmtMultiple(data.evToRevenue)),
_buildMetricCard('Enterprise Value', _formatNumber(data.enterpriseValue)),
_buildMetricCard('Marktkapitalisierung', _formatNumber(data.marketCapitalization)),
_buildMetricCard('52W Höchststand', _fmtCurrency(data.fiftyTwoWeekHigh)),
_buildMetricCard('52W Tiefststand', _fmtCurrency(data.fiftyTwoWeekLow)),
_buildMetricCard('Short Ratio', _fmtMultiple(data.shortRatio)),
],
),
const SizedBox(height: 24),
// 3. Profitability & Financial Health Margins
_buildSectionHeader('Rentabilität & Finanzielle Gesundheit', Icons.account_balance_outlined),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('Bruttomarge (Gross)', _fmtPercent(data.grossMargin)),
_buildMetricCard('Operative Marge', _fmtPercent(data.operatingMargin)),
_buildMetricCard('Nettogewinnmarge', _fmtPercent(data.netProfitMargin)),
_buildMetricCard('Eigenkapitalrendite (ROE)', _fmtPercent(data.returnOnEquity)),
_buildMetricCard('Gesamtkapitalrendite (ROA)', _fmtPercent(data.returnOnAssets)),
_buildMetricCard('ROIC (Invested Capital)', _fmtPercent(data.returnOnInvestedCapital)),
_buildMetricCard('Verschuldungsgrad (D/E)', _fmtMultiple(data.debtToEquity)),
_buildMetricCard('Current Ratio', _fmtMultiple(data.currentRatio)),
_buildMetricCard('Quick Ratio', _fmtMultiple(data.quickRatio)),
_buildMetricCard('Zinsdeckungsgrad', _fmtMultiple(data.interestCoverage)),
],
),
const SizedBox(height: 24),
// 4. Dividends & Ownership
_buildSectionHeader('Dividenden & Aktionärsstruktur', Icons.pie_chart_outline),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: MediaQuery.of(context).size.width > 700 ? 4 : 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: MediaQuery.of(context).size.width > 700 ? 2.2 : 1.8,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildMetricCard('Dividendenrendite', _fmtPercent(data.dividendYield)),
_buildMetricCard('Ausschüttungsquote (Payout)', _fmtPercent(data.payoutRatio)),
_buildMetricCard('Ex-Dividendentag', _fmtDate(data.exDividendDate)),
_buildMetricCard('Nächste Quartalszahlen', _fmtDate(data.nextEarningsDate)),
_buildMetricCard('Institutioneller Anteil', _fmtPercent(data.percentHeldByInstitutions)),
_buildMetricCard('Insider Anteil', _fmtPercent(data.percentHeldByInsiders)),
_buildMetricCard('Short % of Float', _fmtPercent(data.shortPercentOfFloat)),
],
),
const SizedBox(height: 24),
// 5. Financial Statements Section
_buildSectionHeader('Finanzberichte (Statements)', Icons.article_outlined),
const SizedBox(height: 12),
_buildStatementsSection(data),
const SizedBox(height: 24),
// 6. Company Description & Detailed Executive Board
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
const SizedBox(height: 12),
_buildProfileSection(data),
],
),
);
}
return _buildEmptyState();
},
);
}
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 _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 _buildStatementsSection(FundamentalDataModel data) {
// Filter statements by Jährlich / Quartal
final filteredStatements = data.financialStatements
.where((s) => s.periodType.toLowerCase() == _selectedPeriodType.toLowerCase())
.toList();
// Sort descending by date
filteredStatements.sort((a, b) => b.endDate.compareTo(a.endDate));
return GlassContainer(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row containing switches
Row(
children: [
// Period Toggle (Annual / Quarterly)
DropdownButton<String>(
value: _selectedPeriodType,
dropdownColor: AppTheme.cardSurface,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
underline: const SizedBox.shrink(),
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
items: const [
DropdownMenuItem(value: 'Annual', child: Text('Jährlich (Annual)')),
DropdownMenuItem(value: 'Quarterly', child: Text('Quartal (Quarterly)')),
],
onChanged: (val) {
if (val != null) {
setState(() => _selectedPeriodType = val);
}
},
),
const Spacer(),
// Statement Type Selector
Row(
children: [
_buildStatementTabButton('GuV', 'Income'),
const SizedBox(width: 6),
_buildStatementTabButton('Bilanz', 'Balance'),
const SizedBox(width: 6),
_buildStatementTabButton('Cashflow', 'CashFlow'),
],
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
const SizedBox(height: 8),
if (filteredStatements.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'Keine Berichte für diesen Typ vorhanden.',
style: TextStyle(color: AppTheme.textMuted, fontStyle: FontStyle.italic),
),
),
)
else
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Table(
defaultColumnWidth: const FixedColumnWidth(110),
columnWidths: const {
0: FixedColumnWidth(180), // First column containing label is wider
},
border: TableBorder(
horizontalInside: BorderSide(color: Colors.white, width: 0.5),
),
children: _buildTableRows(filteredStatements),
),
),
],
),
);
}
Widget _buildStatementTabButton(String label, String typeCode) {
final isSelected = _selectedStatementType == typeCode;
return InkWell(
onTap: () => setState(() => _selectedStatementType = typeCode),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? AppTheme.primaryEmerald.withValues(alpha: 0.4) : Colors.white10,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? AppTheme.primaryEmerald : Colors.white70,
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
List<TableRow> _buildTableRows(List<FinancialStatementModel> statements) {
final List<TableRow> rows = [];
// Header row containing Dates
rows.add(
TableRow(
children: [
_buildTableCell('Kennzahl (in EUR)', isHeader: true),
...statements.map((s) => _buildTableCell(_fmtDate(s.endDate), isHeader: true)),
],
),
);
if (_selectedStatementType == 'Income') {
rows.add(_buildDataRow('Umsatzerlöse', statements.map((s) => s.totalRevenue).toList()));
rows.add(_buildDataRow('Umsatzkosten', statements.map((s) => s.costOfRevenue).toList()));
rows.add(_buildDataRow('Bruttogewinn', statements.map((s) => s.grossProfit).toList()));
rows.add(_buildDataRow('Operative Aufwendungen', statements.map((s) => s.operatingExpenses).toList()));
rows.add(_buildDataRow('Operatives Ergebnis (EBIT)', statements.map((s) => s.operatingIncome).toList()));
rows.add(_buildDataRow('EBITDA', statements.map((s) => s.ebitda).toList()));
rows.add(_buildDataRow('Jahresüberschuss', statements.map((s) => s.netIncome).toList()));
rows.add(_buildDataRow('EPS (Basic)', statements.map((s) => s.epsBasic).toList(), isCurrency: true));
rows.add(_buildDataRow('EPS (Diluted)', statements.map((s) => s.epsDiluted).toList(), isCurrency: true));
} else if (_selectedStatementType == 'Balance') {
rows.add(_buildDataRow('Liquide Mittel', statements.map((s) => s.cashAndCashEquivalents).toList()));
rows.add(_buildDataRow('Forderungen', statements.map((s) => s.accountsReceivable).toList()));
rows.add(_buildDataRow('Vorräte', statements.map((s) => s.inventory).toList()));
rows.add(_buildDataRow('Umlaufvermögen (Current Assets)', statements.map((s) => s.totalCurrentAssets).toList()));
rows.add(_buildDataRow('Anlagevermögen (Non-Current)', statements.map((s) => s.totalNonCurrentAssets).toList()));
rows.add(_buildDataRow('Kurzfr. Verbindlichkeiten', statements.map((s) => s.currentLiabilities).toList()));
rows.add(_buildDataRow('Langfristige Schulden', statements.map((s) => s.longTermDebt).toList()));
rows.add(_buildDataRow('Gesamtverbindlichkeiten', statements.map((s) => s.totalLiabilities).toList()));
rows.add(_buildDataRow('Eigenkapital (Equity)', statements.map((s) => s.totalStockholdersEquity).toList()));
} else {
rows.add(_buildDataRow('Operativer Cashflow', statements.map((s) => s.operatingCashFlow).toList()));
rows.add(_buildDataRow('Investiver Cashflow', statements.map((s) => s.investingCashFlow).toList()));
rows.add(_buildDataRow('Investitionsausgaben (CapEx)', statements.map((s) => s.capitalExpenditures).toList()));
rows.add(_buildDataRow('Finanzierungs-Cashflow', statements.map((s) => s.financingCashFlow).toList()));
rows.add(_buildDataRow('Free Cashflow', statements.map((s) => s.freeCashFlow).toList()));
}
return rows;
}
TableRow _buildDataRow(String label, List<dynamic> values, {bool isCurrency = false}) {
return TableRow(
children: [
_buildTableCell(label),
...values.map((v) => _buildTableCell(isCurrency ? _fmtCurrency(v) : _formatNumber(v))),
],
);
}
Widget _buildTableCell(String val, {bool isHeader = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Text(
val,
style: TextStyle(
color: isHeader ? AppTheme.accentCyan : Colors.white70,
fontWeight: isHeader ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
);
}
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.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 _buildMetricCard(String label, String value) {
return InkWell(
onTap: () => MetricExplanations.show(context, label),
borderRadius: BorderRadius.circular(10),
child: GlassContainer(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
],
),
const SizedBox(height: 4),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
],
),
),
);
}
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 _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';
final p = (n > 0 && n <= 1) ? 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());
return n != null ? '${n.toStringAsFixed(2)}' : 'N/A';
}
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 ? '-€' : '';
if (absVal >= 1e12) {
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bil.';
} 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(2)} Tsd.';
} else {
return '$prefix${absVal.toStringAsFixed(2)}';
}
}
}