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