import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:shimmer/shimmer.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../core/config/app_config.dart'; import '../../../core/constants/market_list_layout.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/navigation/broker_navigation.dart'; import '../../../core/utils/number_format.dart'; import '../../../core/utils/symbol_display.dart'; import '../../../providers/currency_provider.dart'; import '../../../data/models/announcement/announcement.dart'; import '../../../data/models/home/app_header_item.dart'; import '../../../data/models/home/market_ticker.dart'; import '../../../providers/announcement_popup_provider.dart'; import '../../../providers/announcement_unread_provider.dart'; import '../../../core/network/dio_client.dart' show versionOutdatedProvider; import '../../../providers/app_version_provider.dart'; import '../../../providers/customer_service_provider.dart'; import '../../../providers/asset_provider.dart'; import '../../../providers/home_provider.dart'; import '../../../providers/market_provider.dart' show marketProvider, spotTickerProvider, MarketMode; import '../../widgets/common/app_refresh_indicator.dart'; import '../../widgets/common/coin_icon.dart'; import '../../widgets/common/update_dialog.dart'; import 'activity_carousel.dart'; import 'top_traders_section.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @override ConsumerState createState() => _HomeScreenState(); } class _HomeScreenState extends ConsumerState with WidgetsBindingObserver { bool _popupQueueStarted = false; bool _updateDialogShown = false; Timer? _unreadPollTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _unreadPollTimer = Timer.periodic(const Duration(minutes: 1), (_) { if (mounted) ref.invalidate(announcementUnreadProvider); }); } @override void dispose() { _unreadPollTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { ref.invalidate(announcementUnreadProvider); } } @override Widget build(BuildContext context) { final state = ref.watch(homeProvider); // 监听系统弹窗公告(支持多个,按顺序逐一弹出) ref.listen>>( announcementPopupProvider, (_, next) { final beans = next.valueOrNull; if (beans != null && beans.isNotEmpty && !_popupQueueStarted && context.mounted) { _popupQueueStarted = true; _showAnnouncementPopupQueue(context, beans, 0); } }, ); // 监听版本更新 ref.listen>( appVersionProvider, (_, next) { next.whenData((result) { if (result != null && result.hasUpdate && !_updateDialogShown && context.mounted) { _updateDialogShown = true; UpdateDialog.show(context, result); } }); }, ); // 后端返回 4099 时重新触发版本检查并弹出更新弹窗 ref.listen( versionOutdatedProvider, (_, isOutdated) { if (isOutdated && context.mounted) { _updateDialogShown = false; ref.invalidate(appVersionProvider); } }, ); return Scaffold( body: SafeArea( child: state.isLoading && state.tickers.isEmpty ? _HomeShimmer(isLoggedIn: state.isLoggedIn) : _HomeBody(state: state), ), ); } void _showAnnouncementPopupQueue( BuildContext context, List beans, int index) { if (index >= beans.length || !context.mounted) return; final bean = beans[index]; final cs = Theme.of(context).colorScheme; // 限制弹窗最大高度为屏幕 65%,给 Flexible 提供有界约束 final maxHeight = MediaQuery.of(context).size.height * 0.65; showDialog( context: context, barrierDismissible: true, builder: (dialogContext) => Dialog( backgroundColor: cs.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 48), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 标题(固定高度) Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), child: Text( bean.title, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), ), Divider(height: 1, thickness: 1, color: cs.outline), // Flexible 在 ConstrainedBox 有界约束下正确填充剩余空间 Flexible( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), child: HtmlWidget( bean.content, textStyle: TextStyle( color: cs.onSurface, fontSize: 14, height: 1.6, ), ), ), ), Divider(height: 1, thickness: 1, color: cs.outline), // 确认按钮(固定高度,始终在底部) Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), child: ElevatedButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(AppLocalizations.of(dialogContext)!.iUnderstand), ), ), ], ), ), ), ).whenComplete(() { // 无论按按钮还是点空白关闭,都标记已读并显示下一条 markPopupShown(bean.id); final rid = int.tryParse(bean.id); if (rid != null) markAnnouncementsRead(ref, id: rid); if (context.mounted) { _showAnnouncementPopupQueue(context, beans, index + 1); } }); } } // ── Shimmer 骨架屏 ────────────────────────────────────────── class _HomeShimmer extends StatelessWidget { const _HomeShimmer({required this.isLoggedIn}); final bool isLoggedIn; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Shimmer.fromColors( baseColor: cs.onSurface.withAlpha(15), highlightColor: cs.onSurface.withAlpha(30), child: CustomScrollView( physics: const NeverScrollableScrollPhysics(), slivers: [ // AppBar 占位 SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ _shimmerCircle(32), const SizedBox(width: 10), Expanded( child: Container( height: 36, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), ), ), ), const SizedBox(width: 10), _shimmerCircle(22), const SizedBox(width: 14), _shimmerCircle(22), ], ), ), ), // 资产卡片 / 未登录引导区 占位 if (isLoggedIn) ...[ SliverToBoxAdapter( child: Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 4), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _shimmerBox(80, 13), const SizedBox(height: 10), _shimmerBox(150, 28), const SizedBox(height: 10), _shimmerBox(200, 13), ], ), ), ), // 快捷入口占位 SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: List.generate(5, (_) { return Column( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), ), ), const SizedBox(height: 6), _shimmerBox(30, 10), ], ); }), ), ), ), ] else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), child: Column( children: [ _shimmerCircle(90), const SizedBox(height: 20), Container( height: 52, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), ), ], ), ), ), ], // Banner 占位 SliverToBoxAdapter( child: Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), height: 100, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), ), ), // 行情标题占位 SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), child: Row( children: [ _shimmerBox(60, 14), const SizedBox(width: 20), _shimmerBox(50, 14), const SizedBox(width: 20), _shimmerBox(50, 14), ], ), ), ), // 行情列表行占位 SliverList.builder( itemCount: 6, itemBuilder: (_, __) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _shimmerBox(70, 14), const SizedBox(height: 6), _shimmerBox(50, 11), ], ), ), ], ), ), SizedBox(width: kMarketListNameToPriceGap), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _shimmerBox(80, 14), const SizedBox(height: 6), _shimmerBox(50, 11), ], ), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), Container( width: kMarketListChangeBadgeWidth, height: 30, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), ), ), ], ), ), ), ], ), ); } static Widget _shimmerBox(double width, double height) { return Container( width: width, height: height, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), ), ); } static Widget _shimmerCircle(double size) { return Container( width: size, height: size, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), ); } } // ── 主体内容 ────────────────────────────────────────────── class _HomeBody extends ConsumerWidget { const _HomeBody({required this.state}); final HomeState state; @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(homeProvider.notifier); ref.watch(currencyProvider); // 法币切换时触发行情列表重建 return AppRefreshIndicator( onRefresh: notifier.refresh, child: CustomScrollView( slivers: [ // AppBar SliverToBoxAdapter(child: _HomeAppBar(state: state)), // 未登录:引导区域 if (!state.isLoggedIn) ...[ const SliverToBoxAdapter(child: _GuestHeroSection()), ], // 已登录:资产卡片 + 快捷入口 if (state.isLoggedIn) ...[ SliverToBoxAdapter(child: _AssetCard(state: state)), const SliverToBoxAdapter(child: _QuickActions()), // 活动专区 SliverToBoxAdapter(child: ActivityCarousel(banners: state.banners)), ], // 顶级交易专家(登录/未登录共享) const SliverToBoxAdapter(child: TopTradersSection()), // 热门行情(登录/未登录共享) SliverToBoxAdapter( child: _MarketSectionHeader( tabIndex: state.marketTabIndex, notifier: notifier, showFavorites: state.isLoggedIn, ), ), const SliverToBoxAdapter(child: _MarketListHeader()), // tabIndex=4 展示现货,其余展示合约 if (state.marketTabIndex == 4) _HomeSpotTickerSliver() else SliverList.builder( itemCount: state.displayTickers.length, itemBuilder: (context, index) { final ticker = state.displayTickers[index]; return _MarketTickerRow( ticker: ticker, isFavorite: state.favorites.contains(ticker.symbol), onToggleFavorite: () => notifier.toggleFavorite(ticker.symbol), onTap: () => context.push('/market/futures/${ticker.symbol}'), ); }, ), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ), ); } } // ── AppBar ──────────────────────────────────────────────── class _HomeAppBar extends ConsumerWidget { const _HomeAppBar({required this.state}); final HomeState state; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ // 个人中心入口 Semantics( label: 'home_btn_profile', button: true, enabled: state.isLoggedIn, onTap: state.isLoggedIn ? () => context.push('/user') : null, child: GestureDetector( onTap: () => context.push('/user'), child: Container( width: 32, height: 32, decoration: BoxDecoration( color: state.isLoggedIn ? (isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary) : cs.onSurface.withAlpha(30), shape: BoxShape.circle, ), child: Icon( state.isLoggedIn ? Icons.person : Icons.person_outline, color: state.isLoggedIn ? cs.onSurface : cs.onSurface.withAlpha(153), size: 20, ), ), ), ), const SizedBox(width: 10), // 搜索栏 Expanded( child: Semantics( label: 'home_search_market', onTap: () => context.go('/market'), child: GestureDetector( onTap: () => context.go('/market'), child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, borderRadius: BorderRadius.circular(18), ), child: Row( children: [ Icon(Icons.search, color: cs.onSurface.withAlpha(100), size: 18), const SizedBox(width: 6), Text( AppLocalizations.of(context)!.searchPair, style: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 13, ), ), ], ), ), ), ), ), const SizedBox(width: 10), // 客服入口 if (AppConfig.customerServiceEnabled) Padding( padding: const EdgeInsets.only(right: 14), child: Semantics( label: 'home_btn_service', button: true, onTap: () => openCustomerService(context, ref), child: GestureDetector( onTap: () => openCustomerService(context, ref), child: Icon(Icons.headset_mic_outlined, color: cs.onSurface, size: 22), ), ), ), Semantics( label: 'home_btn_messages', button: true, onTap: () => context.push('/user/messages'), child: GestureDetector( onTap: () => context.push('/user/messages'), child: Stack( clipBehavior: Clip.none, children: [ Icon(Icons.notifications_outlined, color: cs.onSurface, size: 22), if (ref.watch(announcementUnreadProvider).valueOrNull case final s? when s.isLoaded && s.hasUnread) Positioned( top: -2, right: -2, child: Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.fall, shape: BoxShape.circle, ), ), ), ], ), ), ), ], ), ); } } // ── 未登录/已登录:主视觉引导区 ─────────────────────────── class _GuestHeroSection extends ConsumerWidget { // ignore: unused_element_parameter const _GuestHeroSection({this.showLoginButton = true}); final bool showLoginButton; Future _onHeaderTap(BuildContext context, AppHeaderItem item) async { if (item.linkUrl.isEmpty) return; if (item.isExternal) { await launchUrl(Uri.parse(item.linkUrl), mode: LaunchMode.externalApplication); return; } final url = item.linkUrl; // 复用 ActivityCarousel 的跳转逻辑 const tabRoutes = { '/', 'market', '/market', '/futures', '/copy-trading', '/asset' }; final isTabRoute = tabRoutes.any((r) => url == r || url.startsWith('$r/')); if (!context.mounted) return; if (isTabRoute) { context.go(url); } else { context.push(url); } } @override Widget build(BuildContext context, WidgetRef ref) { // 仅在 appHeaders 变化时重建(不因行情 WS 刷新而重建) final appHeaders = ref.watch(homeProvider.select((s) => s.appHeaders)); final children = [ // Header 媒体区域 _HomeHeaderMedia( appHeaders: appHeaders, onHeaderTap: (item) => _onHeaderTap(context, item), ), if (showLoginButton) ...[ const SizedBox(height: 12), // 登录 / 注册 按钮 SizedBox( width: double.infinity, height: 52, child: ElevatedButton( onPressed: () => context.push('/login'), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1E1E1E), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), elevation: 0, ), child: Text( AppLocalizations.of(context)!.loginRegister, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, letterSpacing: 1, ), ), ), ), ] ]; return Padding( padding: EdgeInsets.fromLTRB(16, 8, 16, showLoginButton ? 0 : 12), child: Column(children: children), ); } } // ── Header 媒体区域(统一走图片管线:本地 WebP 动图 / 远程图片)── class _HomeHeaderMedia extends StatelessWidget { const _HomeHeaderMedia({ required this.appHeaders, required this.onHeaderTap, }); final List appHeaders; final Future Function(AppHeaderItem) onHeaderTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final brightness = Theme.of(context).brightness; Widget placeholder() => Container( color: cs.surface, child: Center( child: Icon( Icons.image_outlined, color: cs.onSurface.withAlpha(60), size: 32, ), ), ); Widget errorBox() => Container( color: cs.surface, child: Center( child: Icon( Icons.broken_image_outlined, color: cs.onSurface.withAlpha(80), size: 28, ), ), ); // 本地兜底:深色/浅色 PNG(与 assets/animations 资源一致) Widget localFallback() { final asset = brightness == Brightness.dark ? 'assets/animations/home_header_dart.png' : 'assets/animations/home_header_light.png'; return Image.asset( asset, fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, __, ___) => errorBox(), ); } // 接口返回时用远程图片,否则走本地兜底 Widget content; VoidCallback? onTap; if (appHeaders.isNotEmpty) { final item = appHeaders.first; final url = item.resolveUrl(brightness); onTap = () => onHeaderTap(item); if (url.isNotEmpty) { content = CachedNetworkImage( imageUrl: url, fit: BoxFit.cover, placeholder: (_, __) => placeholder(), errorWidget: (_, __, ___) => localFallback(), ); } else { content = localFallback(); } } else { content = localFallback(); } final media = ClipRRect( borderRadius: BorderRadius.circular(16), child: AspectRatio( aspectRatio: 16 / 9, child: Container( color: Colors.black, child: content, ), ), ); return onTap == null ? media : GestureDetector(onTap: onTap, child: media); } } // ══════════════════════════════════════════════════════════ // 以下是已登录状态下的组件 // ══════════════════════════════════════════════════════════ // ── 已登录:资产卡片 ─────────────────────────────────────── class _AssetCard extends ConsumerWidget { const _AssetCard({required this.state}); final HomeState state; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final assetState = ref.watch(assetProvider); final obscure = assetState.obscureBalance; final pnlColor = AppColors.changeColor(state.todayPnl); final sign = state.todayPnl >= 0 ? '+' : ''; return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 4), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(16), border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( AppLocalizations.of(context)!.totalAssetsValue, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13), ), const SizedBox(width: 6), GestureDetector( onTap: () => ref.read(assetProvider.notifier).toggleObscure(), child: Icon( obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 16, color: cs.onSurface.withAlpha(153), ), ), const Spacer(), GestureDetector( onTap: () => context.push('/asset/deposit'), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(4), ), child: Text( AppLocalizations.of(context)!.recharge, style: const TextStyle( color: Colors.black, fontSize: 12, fontWeight: FontWeight.w500), ), ), ), ], ), const SizedBox(height: 6), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( child: Text( obscure ? '****' : formatPrice(state.totalAsset, decimalPlaces: 2), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, ), ), ), const SizedBox(width: 6), Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( 'USDT', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13), ), ), ], ), const SizedBox(height: 6), Row( children: [ Text( AppLocalizations.of(context)!.todayPnl, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), const SizedBox(width: 8), Flexible( child: Text( obscure ? '****' : state.todayPnlRateAvailable ? '$sign$fiatSymbol${(state.todayPnl * fiatRate).toStringAsFixed(2)} ($sign${state.todayPnlPct.toStringAsFixed(2)}%)' : '$sign$fiatSymbol${(state.todayPnl * fiatRate).toStringAsFixed(2)} (--)', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: obscure ? cs.onSurface.withAlpha(153) : pnlColor, fontSize: 13, fontWeight: FontWeight.w500, ), ), ), ], ), ], ), ); } } // ── 快捷入口 ────────────────────────────────────────────── class _QuickActions extends ConsumerWidget { const _QuickActions(); static const _actionDefs = [ ( icon: Icons.trending_up, key: 'perpetual', route: '/futures/BTCUSDT', useGo: true ), ( icon: Icons.people_alt_outlined, key: 'copy', route: '/copy-trading', useGo: true ), ( icon: Icons.account_balance_wallet_outlined, key: 'recharge', route: '/asset/deposit', useGo: false ), ( icon: Icons.card_giftcard_outlined, key: 'invite', route: '/user/referral', useGo: false ), ( icon: Icons.business_center_outlined, key: 'broker', route: '/broker', useGo: false ), (icon: Icons.auto_graph, key: 'ido', route: '/finance/ido', useGo: false), ( icon: Icons.bar_chart_rounded, key: 'commodity', route: '', useGo: false ), ( icon: Icons.candlestick_chart, key: 'stock', route: '', useGo: false ), (icon: Icons.currency_exchange, key: 'forex', route: '', useGo: false), (icon: Icons.apps_outlined, key: 'app', route: '', useGo: false), ]; Future _onBrokerTap(BuildContext context, WidgetRef ref) async { await openBrokerEntry(context, ref); } static void _showDevelopingDialog(BuildContext context) { showDialog( context: context, barrierDismissible: true, builder: (dialogContext) => Dialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), insetPadding: const EdgeInsets.symmetric(horizontal: 48, vertical: 48), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( '敬请期待', style: TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), const Text( '该功能正在开发中', style: TextStyle( color: Color(0xFF666666), fontSize: 13, ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: TextButton( onPressed: () => Navigator.of(dialogContext).pop(), style: TextButton.styleFrom( backgroundColor: Colors.black, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.symmetric(vertical: 12), ), child: const Text('确定'), ), ), ], ), ), ), ); } @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; final labelMap = { 'perpetual': l10n.perpetualFutures, 'copy': l10n.copyTrading, 'recharge': l10n.recharge, 'invite': l10n.inviteFriends, 'broker': l10n.broker, 'ido': 'IDO理财', 'commodity': '大宗贵金属', 'stock': '股票', 'forex': '外汇', 'app': '应用', }; final semanticsMap = { 'perpetual': 'home_btn_futures', 'copy': 'home_btn_copy_trading', 'recharge': 'home_btn_deposit', 'invite': 'home_btn_referral', 'broker': 'home_btn_broker', 'ido': 'home_btn_ido', 'commodity': 'home_btn_commodity', 'stock': 'home_btn_stock', 'forex': 'home_btn_forex', 'app': 'home_btn_app', }; const developingKeys = {'commodity', 'stock', 'forex', 'app'}; List buildActionItems() { return _actionDefs.map((action) { return _QuickActionItem( icon: action.icon, label: labelMap[action.key]!, semanticsLabel: semanticsMap[action.key]!, onTap: developingKeys.contains(action.key) ? () => _showDevelopingDialog(context) : action.key == 'broker' ? () => _onBrokerTap(context, ref) : () => action.useGo ? context.go(action.route) : context.push(action.route), ); }).toList(); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: GridView.count( crossAxisCount: 5, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 12, crossAxisSpacing: 4, childAspectRatio: 0.84, children: buildActionItems(), ), ); } } class _QuickActionItem extends StatelessWidget { const _QuickActionItem({ required this.icon, required this.label, required this.semanticsLabel, required this.onTap, }); final IconData icon; final String label; final String semanticsLabel; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return Semantics( label: semanticsLabel, button: true, onTap: onTap, child: GestureDetector( onTap: onTap, child: SizedBox( width: 62, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, borderRadius: BorderRadius.circular(14), ), child: Icon(icon, color: AppColors.brand, size: 22), ), const SizedBox(height: 6), Text( label, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, ), textAlign: TextAlign.center, ), ], ), ), ), ); } } // ── 行情区块:标题 + Tab ────────────────────────────────── class _MarketSectionHeader extends StatelessWidget { const _MarketSectionHeader({ required this.tabIndex, required this.notifier, this.showFavorites = true, }); final int tabIndex; final HomeNotifier notifier; final bool showFavorites; List _getTabs(BuildContext context) { final l10n = AppLocalizations.of(context)!; return [l10n.hotTrading, l10n.gainers, l10n.losers, l10n.spotTab]; } static const _tabIndices = [1, 2, 3, 4]; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), child: Row( children: List.generate(_tabIndices.length, (i) { final tabs = _getTabs(context); final actualIndex = _tabIndices[i]; final selected = actualIndex == tabIndex; final tabSemantics = [ 'home_tab_market_hot', 'home_tab_market_gainers', 'home_tab_market_losers', 'home_tab_market_spot' ]; return Semantics( label: tabSemantics[i], button: true, enabled: true, onTap: () => notifier.setMarketTab(actualIndex), child: GestureDetector( onTap: () => notifier.setMarketTab(actualIndex), child: Padding( padding: const EdgeInsets.only(right: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tabs[i], style: TextStyle( fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? cs.onSurface : cs.onSurface.withAlpha(153), ), ), const SizedBox(height: 4), if (selected) Container(height: 2, width: 20, color: AppColors.brand), ], ), ), ), ); }), ), ); } } // ── 行情列表表头 ────────────────────────────────────────── // ── 首页现货行情 Sliver(tabIndex=4)───────────────────────── class _HomeSpotTickerSliver extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final symbols = ref.watch( marketProvider.select((s) => s.spotDisplaySymbols.take(10).toList()), ); final spotLoading = ref.watch( marketProvider.select((s) => s.spotLoading), ); // 首次进入现货 Tab 时触发加载 final loaded = ref.watch(marketProvider.select((s) => s.spotTickers.isNotEmpty)); if (!loaded && !spotLoading) { Future.microtask(() { ref.read(marketProvider.notifier).setMode(MarketMode.spot); }); } if (spotLoading || symbols.isEmpty) { return SliverToBoxAdapter( child: SizedBox( height: 80, child: const Center(child: CircularProgressIndicator()), ), ); } return SliverList.builder( itemCount: symbols.length, itemBuilder: (context, index) { final sym = symbols[index]; return _HomeSpotTickerRow(symbol: sym); }, ); } } class _HomeSpotTickerRow extends ConsumerWidget { const _HomeSpotTickerRow({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final ticker = ref.watch(spotTickerProvider(symbol)); if (ticker == null) return const SizedBox.shrink(); final changeColor = AppColors.changeColor(ticker.change24h); final changeStr = formatChange(ticker.change24h); return InkWell( onTap: () => context.push('/market/spot/${ticker.symbol}'), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: Row( children: [ CoinIcon( symbol: ticker.baseAsset, iconUrl: ticker.icon, size: 36, borderRadius: 10, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( formatUsdtPairDisplay(ticker.symbol), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( AppLocalizations.of(context)!.spot, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, ), ), ], ), ), ], ), ), SizedBox(width: kMarketListNameToPriceGap), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( ticker.lastPrice > 0 ? (ticker.lastPriceStr != null ? formatRawPrice(ticker.lastPriceStr!) : formatPrice(ticker.lastPrice)) : '--', style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( ticker.lastPrice > 0 ? formatFiatPrice(ticker.lastPrice) : '--', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), SizedBox( width: kMarketListChangeBadgeWidth, child: Container( height: 34, alignment: Alignment.center, decoration: BoxDecoration( color: changeColor, borderRadius: BorderRadius.circular(6), ), child: Text( changeStr, style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()], ), textAlign: TextAlign.center, ), ), ), ], ), ), ); } } class _MarketListHeader extends StatelessWidget { const _MarketListHeader(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ const SizedBox(width: 46), Expanded( flex: kMarketListNameClusterFlex, child: Text( AppLocalizations.of(context)!.coinNameLabel, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12, ), ), ), SizedBox(width: kMarketListNameToPriceGap), Text( AppLocalizations.of(context)!.latestPrice, textAlign: TextAlign.end, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12, ), ), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), SizedBox( width: kMarketListChangeBadgeWidth, child: Text( AppLocalizations.of(context)!.change24h, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12, ), ), ), ], ), ); } } // ── 行情行 ──────────────────────────────────────────────── class _MarketTickerRow extends StatelessWidget { const _MarketTickerRow({ required this.ticker, required this.isFavorite, required this.onToggleFavorite, required this.onTap, }); final MarketTicker ticker; final bool isFavorite; final VoidCallback onToggleFavorite; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final color = AppColors.changeColor(ticker.change24h); final changeStr = formatChange(ticker.change24h); final vol = ticker.volume24h; final volStr = vol >= 1e9 ? '${(vol / 1e9).toStringAsFixed(2)}B' : '${(vol / 1e6).toStringAsFixed(0)}M'; return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Expanded( flex: kMarketListNameClusterFlex, child: Row( children: [ CoinIcon( symbol: ticker.baseAsset, iconUrl: ticker.icon, size: 36, borderRadius: 10), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( formatUsdtPairDisplay(ticker.symbol), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( ticker.isFutures ? '${AppLocalizations.of(context)!.turnover} $volStr' : AppLocalizations.of(context)!.spot, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), SizedBox(width: kMarketListNameToPriceGap), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( ticker.lastPriceStr != null ? formatRawPrice(ticker.lastPriceStr!) : formatPrice(ticker.lastPrice, decimalPlaces: ticker.pricePrecision), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( formatFiatPrice(ticker.lastPrice, pricePrecision: ticker.pricePrecision), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: TextAlign.end, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), Spacer(flex: kMarketListPriceTailSpacerFlex), SizedBox(width: kMarketListPriceToBadgeGap), SizedBox( width: kMarketListChangeBadgeWidth, child: Container( height: 34, alignment: Alignment.center, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(6), ), child: Text( changeStr, style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()], ), textAlign: TextAlign.center, ), ), ), ], ), ), ); } }