feat(app): responsive asset detail layout, full width chart, reactive hero header, shimmer loaders and enriched fundamentals

This commit is contained in:
2026-08-14 23:57:03 +02:00
parent f94e3b8164
commit 1d244b338a
22 changed files with 1950 additions and 1074 deletions
@@ -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;
}
}