feat(app): responsive asset detail layout, full width chart, reactive hero header, shimmer loaders and enriched fundamentals
This commit is contained in:
+93
-88
@@ -37,7 +37,7 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,8 +69,6 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||
forceRefresh: true,
|
||||
exchange: _selectedExchange,
|
||||
ticker: _selectedTicker));
|
||||
// AssetFundamentalsBloc is omitted here because AssetHeaderBloc already triggers forceRefresh=true
|
||||
// for fundamentals, and the listener below will fetch the updated data with forceRefresh=false.
|
||||
context.read<AssetTechnicalBloc>().add(LoadAssetTechnical(widget.isin,
|
||||
ticker: _selectedTicker, forceRefresh: true));
|
||||
context.read<AssetTradesBloc>().add(LoadAssetTrades(widget.isin));
|
||||
@@ -86,10 +84,8 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||
if (_selectedTicker == null) {
|
||||
setState(() {
|
||||
_selectedTicker = widget.selectedTicker;
|
||||
//_selectedExchange = state.data!.exchange;
|
||||
});
|
||||
}
|
||||
// Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh)
|
||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
||||
widget.isin,
|
||||
ticker: _selectedTicker,
|
||||
@@ -100,92 +96,101 @@ class _AssetPageDesktopLayoutState extends State<AssetPageDesktopLayout>
|
||||
forceRefresh: false));
|
||||
}
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final height = constraints.maxHeight.isFinite
|
||||
? constraints.maxHeight
|
||||
: MediaQuery.of(context).size.height;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
AssetHeroHeader(
|
||||
isin: widget.isin,
|
||||
name: widget.name ?? widget.isin,
|
||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||
onExchangeChanged: _handleExchangeChanged,
|
||||
onForceRefresh: _handleForceRefresh,
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Left Panel (Chart Focus)
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.glassBorder),
|
||||
),
|
||||
child: TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
isDesktopLeftPanel: true),
|
||||
),
|
||||
),
|
||||
// Right Panel (Tabs for fundamentals/trades)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: 16, right: 16, bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: theme.primaryColor,
|
||||
unselectedLabelColor: theme.textMuted,
|
||||
indicatorColor: theme.primaryColor,
|
||||
dividerColor: theme.glassBorder,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 13),
|
||||
tabs: const [
|
||||
Tab(text: 'OVERVIEW'),
|
||||
Tab(text: 'TRADES'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
FundamentalsTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
),
|
||||
TradesTab(symbol: widget.isin),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. Hero Header
|
||||
AssetHeroHeader(
|
||||
isin: widget.isin,
|
||||
name: widget.name ?? widget.isin,
|
||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||
onExchangeChanged: _handleExchangeChanged,
|
||||
onForceRefresh: _handleForceRefresh,
|
||||
),
|
||||
|
||||
// 2. Full-Width Interactive Chart Section
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.glassBorder),
|
||||
),
|
||||
child: TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
showChartOnly: true,
|
||||
chartHeight: 460,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 3. Detailed Sections & Fundamentals under the Chart
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: theme.primaryColor,
|
||||
unselectedLabelColor: theme.textMuted,
|
||||
indicatorColor: theme.primaryColor,
|
||||
dividerColor: theme.glassBorder,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 13),
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.analytics_outlined, size: 18),
|
||||
text: 'FUNDAMENTALS & ÜBERSICHT'),
|
||||
Tab(
|
||||
icon: Icon(Icons.architecture_outlined, size: 18),
|
||||
text: 'MUSTER & SIGNALE'),
|
||||
Tab(
|
||||
icon: Icon(Icons.candlestick_chart_outlined, size: 18),
|
||||
text: 'TRADES'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
AnimatedBuilder(
|
||||
animation: _tabController,
|
||||
builder: (context, _) {
|
||||
switch (_tabController.index) {
|
||||
case 0:
|
||||
return FundamentalsTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
isEmbedded: true,
|
||||
);
|
||||
case 1:
|
||||
return TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
showDetailsOnly: true,
|
||||
);
|
||||
case 2:
|
||||
return SizedBox(
|
||||
height: 600,
|
||||
child: TradesTab(symbol: widget.isin),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,10 +84,8 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
||||
if (_selectedTicker == null) {
|
||||
setState(() {
|
||||
_selectedTicker = widget.selectedTicker;
|
||||
//_selectedExchange = state.data!.exchange;
|
||||
});
|
||||
}
|
||||
// Re-trigger fundamentals and TA with resolved ticker whenever header loads (e.g. after force refresh)
|
||||
context.read<AssetFundamentalsBloc>().add(LoadAssetFundamentals(
|
||||
widget.isin,
|
||||
ticker: _selectedTicker,
|
||||
@@ -98,82 +96,96 @@ class _AssetPageMobileLayoutState extends State<AssetPageMobileLayout>
|
||||
forceRefresh: false));
|
||||
}
|
||||
},
|
||||
child: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: AssetHeroHeader(
|
||||
isin: widget.isin,
|
||||
name: widget.name ?? widget.isin,
|
||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||
onExchangeChanged: _handleExchangeChanged,
|
||||
onForceRefresh: _handleForceRefresh,
|
||||
),
|
||||
),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _SliverAppBarDelegate(
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: theme.primaryColor,
|
||||
unselectedLabelColor: theme.textMuted,
|
||||
indicatorColor: theme.primaryColor,
|
||||
dividerColor: Colors.transparent,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 13),
|
||||
tabs: const [
|
||||
Tab(text: 'OVERVIEW'),
|
||||
Tab(text: 'TECHNICAL'),
|
||||
Tab(text: 'TRADES'),
|
||||
],
|
||||
),
|
||||
theme.cardSurface,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FundamentalsTab(
|
||||
// 1. Hero Header
|
||||
AssetHeroHeader(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
name: widget.name ?? widget.isin,
|
||||
symbol: _selectedTicker ?? widget.selectedTicker,
|
||||
onExchangeChanged: _handleExchangeChanged,
|
||||
onForceRefresh: _handleForceRefresh,
|
||||
),
|
||||
TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
|
||||
// 2. Full-Width Interactive Chart Section
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.glassBorder),
|
||||
),
|
||||
child: TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
showChartOnly: true,
|
||||
chartHeight: 330,
|
||||
),
|
||||
),
|
||||
TradesTab(symbol: widget.isin),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// 3. Tab Bar & Detailed Sections (Fundamentals, Signals, Trades)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.glassBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: theme.primaryColor,
|
||||
unselectedLabelColor: theme.textMuted,
|
||||
indicatorColor: theme.primaryColor,
|
||||
dividerColor: theme.glassBorder,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 12),
|
||||
tabs: const [
|
||||
Tab(text: 'FUNDAMENTALS'),
|
||||
Tab(text: 'MUSTER & SIGNALE'),
|
||||
Tab(text: 'TRADES'),
|
||||
],
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _tabController,
|
||||
builder: (context, _) {
|
||||
switch (_tabController.index) {
|
||||
case 0:
|
||||
return FundamentalsTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
isEmbedded: true,
|
||||
);
|
||||
case 1:
|
||||
return TechnicalTab(
|
||||
isin: widget.isin,
|
||||
symbol: _selectedTicker,
|
||||
showDetailsOnly: true,
|
||||
);
|
||||
case 2:
|
||||
return SizedBox(
|
||||
height: 500,
|
||||
child: TradesTab(symbol: widget.isin),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final TabBar _tabBar;
|
||||
final Color _backgroundColor;
|
||||
|
||||
_SliverAppBarDelegate(this._tabBar, this._backgroundColor);
|
||||
|
||||
@override
|
||||
double get minExtent => _tabBar.preferredSize.height;
|
||||
|
||||
@override
|
||||
double get maxExtent => _tabBar.preferredSize.height;
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return Container(
|
||||
color: _backgroundColor,
|
||||
child: _tabBar,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
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/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';
|
||||
|
||||
class FundamentalsTab extends StatefulWidget {
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
const FundamentalsTab({super.key, this.symbol, required this.isin});
|
||||
final bool isEmbedded;
|
||||
|
||||
const FundamentalsTab({
|
||||
super.key,
|
||||
this.symbol,
|
||||
this.isEmbedded = false,
|
||||
required this.isin,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FundamentalsTab> createState() => _FundamentalsTabState();
|
||||
}
|
||||
|
||||
class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
String _selectedPeriodType = 'Annual'; // 'Annual' or 'Quarterly'
|
||||
String _selectedStatementType = 'Income'; // 'Income', 'Balance', 'CashFlow'
|
||||
String _sym = '\$';
|
||||
String _curCode = 'USD';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,7 +45,7 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
return BlocBuilder<AssetFundamentalsBloc, AssetFundamentalsState>(
|
||||
builder: (context, state) {
|
||||
if (state is AssetFundamentalsLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||
return _buildFundamentalsShimmer(context);
|
||||
}
|
||||
|
||||
if (state is AssetFundamentalsError) {
|
||||
@@ -64,11 +72,16 @@ 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();
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -77,87 +90,11 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
_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),
|
||||
// 2. Responsive Side-by-Side Category List Panels (Valuation, Profitability, Dividends)
|
||||
_buildCategoryPanels(data),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 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
|
||||
// 3. Company Description & Detailed Executive Board
|
||||
_buildSectionHeader('Unternehmensprofil & Führungskräfte', Icons.business_outlined),
|
||||
const SizedBox(height: 12),
|
||||
_buildProfileSection(data),
|
||||
@@ -219,180 +156,95 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatementsSection(FundamentalDataModel data) {
|
||||
// Filter statements by Jährlich / Quartal
|
||||
final filteredStatements = data.financialStatements
|
||||
.where((s) => s.periodType.toLowerCase() == _selectedPeriodType.toLowerCase())
|
||||
.toList();
|
||||
Widget _buildFundamentalsShimmer(BuildContext context) {
|
||||
final isDesktop = MediaQuery.of(context).size.width >= 1050;
|
||||
final isTablet = MediaQuery.of(context).size.width >= 680 && MediaQuery.of(context).size.width < 1050;
|
||||
|
||||
// Sort descending by date
|
||||
filteredStatements.sort((a, b) => b.endDate.compareTo(a.endDate));
|
||||
Widget panelShimmer() {
|
||||
return GlassContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ShimmerLoading(width: 180, height: 18, borderRadius: 6),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(color: Colors.white10, height: 1),
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < 9; i++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
ShimmerLoading(width: 100, height: 14, borderRadius: 4),
|
||||
ShimmerLoading(width: 60, height: 14, borderRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GlassContainer(
|
||||
return SingleChildScrollView(
|
||||
physics: widget.isEmbedded ? const NeverScrollableScrollPhysics() : null,
|
||||
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),
|
||||
// Price Target Card Shimmer
|
||||
const ShimmerLoading(width: double.infinity, height: 86, borderRadius: 16),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
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),
|
||||
// 3 Category Panels Shimmer
|
||||
if (isDesktop)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: panelShimmer()),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: panelShimmer()),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: panelShimmer()),
|
||||
],
|
||||
)
|
||||
else if (isTablet)
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: panelShimmer()),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: panelShimmer()),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
panelShimmer(),
|
||||
],
|
||||
)
|
||||
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),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
panelShimmer(),
|
||||
const SizedBox(height: 12),
|
||||
panelShimmer(),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -532,43 +384,197 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricCard(String label, String value) {
|
||||
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(10),
|
||||
child: GlassContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: AppTheme.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.info_outline, size: 12, color: AppTheme.textMuted),
|
||||
Icon(Icons.info_outline, size: 11, color: AppTheme.textMuted.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -583,18 +589,38 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
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';
|
||||
final p = (n > 0 && n <= 1) ? n * 100 : n;
|
||||
return '${p.toStringAsFixed(2)}%';
|
||||
// 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());
|
||||
return n != null ? '€${n.toStringAsFixed(2)}' : 'N/A';
|
||||
if (n == null || n == 0) return 'N/A';
|
||||
return '$_sym${n.toStringAsFixed(2)}';
|
||||
}
|
||||
|
||||
String _fmtDate(dynamic val) {
|
||||
@@ -610,18 +636,62 @@ class _FundamentalsTabState extends State<FundamentalsTab> {
|
||||
|
||||
final isNegative = n < 0;
|
||||
final absVal = n.abs();
|
||||
final prefix = isNegative ? '-€' : '€';
|
||||
final prefix = isNegative ? '-$_sym' : _sym;
|
||||
|
||||
if (absVal >= 1e12) {
|
||||
return '$prefix${(absVal / 1e12).toStringAsFixed(2)} Bil.';
|
||||
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(2)} Tsd.';
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
|
||||
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/technical/asset_technical_bloc.dart';
|
||||
import '../../bloc/technical/asset_technical_event.dart';
|
||||
@@ -15,11 +16,17 @@ class TechnicalTab extends StatefulWidget {
|
||||
final String isin;
|
||||
final String? symbol;
|
||||
final bool isDesktopLeftPanel;
|
||||
final bool showChartOnly;
|
||||
final bool showDetailsOnly;
|
||||
final double chartHeight;
|
||||
|
||||
const TechnicalTab({
|
||||
super.key,
|
||||
this.symbol,
|
||||
this.isDesktopLeftPanel = false,
|
||||
this.showChartOnly = false,
|
||||
this.showDetailsOnly = false,
|
||||
this.chartHeight = 420,
|
||||
required this.isin,
|
||||
});
|
||||
|
||||
@@ -53,8 +60,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||
builder: (context, state) {
|
||||
if (state is AssetTechnicalLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryEmerald));
|
||||
return _buildTechnicalShimmer(context);
|
||||
}
|
||||
|
||||
if (state is AssetTechnicalError) {
|
||||
@@ -132,166 +138,186 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
if (!_disabledPatternIndices.contains(i)) patterns[i]
|
||||
];
|
||||
|
||||
final chartRibbon = GlassContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildIndicatorChip(
|
||||
'EMA (20)',
|
||||
_showEma,
|
||||
(v) => setState(() => _showEma = v),
|
||||
Colors.blueAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'SMA (50)',
|
||||
_showSma50,
|
||||
(v) => setState(() => _showSma50 = v),
|
||||
Colors.orangeAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'SMA (200)',
|
||||
_showSma200,
|
||||
(v) => setState(() => _showSma200 = v),
|
||||
Colors.redAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'Supertrend',
|
||||
_showSupertrend,
|
||||
(v) => setState(() => _showSupertrend = v),
|
||||
AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'Alle Muster',
|
||||
_showPatterns,
|
||||
(v) => setState(() => _showPatterns = v),
|
||||
Colors.amberAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'Signale',
|
||||
_showSignals,
|
||||
(v) => setState(() => _showSignals = v),
|
||||
AppTheme.accentCyan),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final chartWidget = SizedBox(
|
||||
height: widget.chartHeight,
|
||||
width: double.infinity,
|
||||
child: CandlestickChart(
|
||||
candles: candles,
|
||||
patterns: activePatterns,
|
||||
signals: signals,
|
||||
indicators: indicators,
|
||||
showPatterns: _showPatterns,
|
||||
showEma: _showEma,
|
||||
showSma50: _showSma50,
|
||||
showSma200: _showSma200,
|
||||
showSignals: _showSignals,
|
||||
showSupertrend: _showSupertrend,
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.showChartOnly) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
chartRibbon,
|
||||
const SizedBox(height: 8),
|
||||
chartWidget,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final detailsSection = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.architecture_outlined,
|
||||
color: AppTheme.primaryEmerald, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Erkannte Chart-Muster & Signale',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
],
|
||||
),
|
||||
if (patterns.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_disabledPatternIndices.length ==
|
||||
patterns.length) {
|
||||
_disabledPatternIndices.clear();
|
||||
} else {
|
||||
_disabledPatternIndices.addAll(
|
||||
List.generate(
|
||||
patterns.length, (i) => i));
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_disabledPatternIndices.isEmpty
|
||||
? Icons.deselect
|
||||
: Icons.select_all,
|
||||
size: 16,
|
||||
color: Colors.amberAccent),
|
||||
label: Text(
|
||||
_disabledPatternIndices.isEmpty
|
||||
? 'Alle abwählen'
|
||||
: 'Alle anwählen',
|
||||
style: const TextStyle(
|
||||
color: Colors.amberAccent, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (patterns.isEmpty && signals.isEmpty)
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textMuted, fontSize: 12)),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (patterns.isNotEmpty) ...[
|
||||
Text(
|
||||
'Formationen & Trendlinien (Mit Checkbox im Chart schalten):',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
const SizedBox(height: 6),
|
||||
...List.generate(
|
||||
patterns.length,
|
||||
(index) =>
|
||||
_buildPatternCard(patterns[index], index)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (signals.isNotEmpty) ...[
|
||||
Text('Strategie-Signale:',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
const SizedBox(height: 6),
|
||||
...signals.map((s) => _buildSignalCard(s)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.showDetailsOnly) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: detailsSection,
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Glassmorphic Indicator & Pattern Control Ribbon
|
||||
GlassContainer(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildIndicatorChip(
|
||||
'EMA (20)',
|
||||
_showEma,
|
||||
(v) => setState(() => _showEma = v),
|
||||
Colors.blueAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'SMA (50)',
|
||||
_showSma50,
|
||||
(v) => setState(() => _showSma50 = v),
|
||||
Colors.orangeAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'SMA (200)',
|
||||
_showSma200,
|
||||
(v) => setState(() => _showSma200 = v),
|
||||
Colors.redAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'Supertrend',
|
||||
_showSupertrend,
|
||||
(v) => setState(() => _showSupertrend = v),
|
||||
AppTheme.primaryEmerald),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'Alle Muster',
|
||||
_showPatterns,
|
||||
(v) => setState(() => _showPatterns = v),
|
||||
Colors.amberAccent),
|
||||
const SizedBox(width: 6),
|
||||
_buildIndicatorChip(
|
||||
'Signale',
|
||||
_showSignals,
|
||||
(v) => setState(() => _showSignals = v),
|
||||
AppTheme.accentCyan),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
chartRibbon,
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Interactive Candlestick Chart
|
||||
SizedBox(
|
||||
height: 380,
|
||||
child: CandlestickChart(
|
||||
candles: candles,
|
||||
patterns: activePatterns,
|
||||
signals: signals,
|
||||
indicators: indicators,
|
||||
showPatterns: _showPatterns,
|
||||
showEma: _showEma,
|
||||
showSma50: _showSma50,
|
||||
showSma200: _showSma200,
|
||||
showSignals: _showSignals,
|
||||
showSupertrend: _showSupertrend,
|
||||
),
|
||||
),
|
||||
chartWidget,
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Dedicated Chart Patterns & Signal Description List Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.architecture_outlined,
|
||||
color: AppTheme.primaryEmerald, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Erkannte Chart-Muster & Signale',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
],
|
||||
),
|
||||
if (patterns.isNotEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_disabledPatternIndices.length ==
|
||||
patterns.length) {
|
||||
_disabledPatternIndices.clear();
|
||||
} else {
|
||||
_disabledPatternIndices.addAll(
|
||||
List.generate(
|
||||
patterns.length, (i) => i));
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_disabledPatternIndices.isEmpty
|
||||
? Icons.deselect
|
||||
: Icons.select_all,
|
||||
size: 16,
|
||||
color: Colors.amberAccent),
|
||||
label: Text(
|
||||
_disabledPatternIndices.isEmpty
|
||||
? 'Alle abwählen'
|
||||
: 'Alle anwählen',
|
||||
style: const TextStyle(
|
||||
color: Colors.amberAccent, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (patterns.isEmpty && signals.isEmpty)
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textMuted, fontSize: 12)),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (patterns.isNotEmpty) ...[
|
||||
Text(
|
||||
'Formationen & Trendlinien (Mit Checkbox im Chart schalten):',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
const SizedBox(height: 6),
|
||||
...List.generate(
|
||||
patterns.length,
|
||||
(index) =>
|
||||
_buildPatternCard(patterns[index], index)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (signals.isNotEmpty) ...[
|
||||
Text('Strategie-Signale:',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
const SizedBox(height: 6),
|
||||
...signals.map((s) => _buildSignalCard(s)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
detailsSection,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
@@ -506,4 +532,54 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTechnicalShimmer(BuildContext context) {
|
||||
if (widget.showChartOnly) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||
const SizedBox(height: 8),
|
||||
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.showDetailsOnly) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||
const SizedBox(height: 14),
|
||||
for (int i = 0; i < 4; i++) ...[
|
||||
const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||
const SizedBox(height: 8),
|
||||
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
|
||||
const SizedBox(height: 16),
|
||||
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||
const SizedBox(height: 14),
|
||||
for (int i = 0; i < 3; i++) ...[
|
||||
const ShimmerLoading(width: double.infinity, height: 68, borderRadius: 12),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
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 'package:finlytic_app/features/trades/models/trade_model.dart';
|
||||
|
||||
@@ -553,7 +554,7 @@ class _TradesTabState extends State<TradesTab> {
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (state is AssetTradesLoading)
|
||||
Center(child: Padding(padding: const EdgeInsets.all(32), child: CircularProgressIndicator(color: AppTheme.primaryEmerald)))
|
||||
_buildTradesShimmer(context)
|
||||
else if (state is AssetTradesError)
|
||||
GlassContainer(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -583,6 +584,20 @@ class _TradesTabState extends State<TradesTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTradesShimmer(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ShimmerLoading(width: 260, height: 20, borderRadius: 6),
|
||||
const SizedBox(height: 12),
|
||||
for (int i = 0; i < 3; i++) ...[
|
||||
const ShimmerLoading(width: double.infinity, height: 105, borderRadius: 14),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTradeList(String title, List<TradeModel> trades) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -649,7 +664,7 @@ class _TradesTabState extends State<TradesTab> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Row: Side, Status, Instrument, Action Buttons
|
||||
// Header Row: Side, Status, Instrument, Action Buttons cv
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user