import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gal/gal.dart'; import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; import '../../../core/utils/avatar_urls.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/network/dio_client.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/dialog_utils.dart'; import '../../../core/utils/top_toast.dart'; import '../../../data/repositories/copy_trading_repository.dart'; import '../../../data/models/copy_trading/trader.dart'; import '../../../data/services/auth_service.dart'; import '../../../providers/app_provider.dart'; import '../../../providers/auth_provider.dart'; import '../../../providers/copy_trading_provider.dart'; import '../../../providers/my_copy_trading_provider.dart'; import '../../widgets/common/app_shimmer.dart'; // ── Provider ────────────────────────────────────────────────────────────────── final _traderDetailProvider = FutureProvider.autoDispose.family?, String>( (ref, traderId) => ref.read(copyTradingRepositoryProvider).getTraderInfo(traderId), ); // ── Screen ──────────────────────────────────────────────────────────────────── class TraderDetailScreen extends ConsumerStatefulWidget { const TraderDetailScreen({super.key, required this.traderId}); final String traderId; @override ConsumerState createState() => _TraderDetailScreenState(); } class _TraderDetailScreenState extends ConsumerState with SingleTickerProviderStateMixin { // ── Tab: 历史带单=0 / 当前带单=1 late TabController _tabController; final _scrollCtrl = ScrollController(); // ── Favorite / Follow state bool _isFavorite = false; bool _isFollowing = false; bool _favoriteLoading = false; bool _followLoading = false; bool _initialized = false; // ── 带单合约 bool _symbolExpanded = true; List _traderSymbols = []; // ── 当前带单 List> _currentOrders = []; bool _loadingCurrent = false; bool _currentLoaded = false; int _currentPage = 1; bool _currentHasMore = true; bool _currentLoadingMore = false; // ── 历史带单 List> _historyOrders = []; bool _loadingHistory = false; bool _historyLoaded = false; int _historyPage = 1; bool _historyHasMore = true; bool _historyLoadingMore = false; static const _pageSize = 10; static const _avatarColors = [ Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff), Color(0xFFf3ba2f), Color(0xFF2775ca), Color(0xFF00aae4), ]; @override void initState() { super.initState(); // 默认显示「当前带单」(index 0) _tabController = TabController(length: 2, vsync: this, initialIndex: 0); _tabController.addListener(() { if (!mounted) return; if (!_tabController.indexIsChanging) { // 切换到历史带单 tab 时重新拉取 if (_tabController.index == 1) _loadHistory(); setState(() {}); // 仅更新 tab 标题计数 } }); // 进入页面同时预加载两个 tab 的数据 _loadCurrent(); _loadHistory(); _loadTraderSymbols(); } @override void dispose() { _tabController.dispose(); _scrollCtrl.dispose(); super.dispose(); } Future _loadTraderSymbols() async { try { final list = await ref .read(copyTradingRepositoryProvider) .getTraderSymbols(widget.traderId); if (mounted) { setState(() { _traderSymbols = list .map((s) => s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '') .where((n) => n.isNotEmpty) .toList() ..sort(); }); } } catch (_) {} } Color _avatarBg(String name) => _avatarColors[ name.isEmpty ? 0 : name.codeUnitAt(0) % _avatarColors.length]; String _fmt(dynamic raw, {int decimals = 2}) { if (raw == null) return '--'; final d = double.tryParse(raw.toString()); if (d == null) return '--'; return d.toStringAsFixed(decimals); } String _fmtPercent(dynamic raw) { if (raw == null) return '--'; final d = double.tryParse(raw.toString()); if (d == null) return '--'; return '${d >= 0 ? '+' : ''}${d.toStringAsFixed(2)}%'; } Color _pnlColor(dynamic raw) { final d = double.tryParse(raw?.toString() ?? ''); if (d == null) return AppColors.darkTextSecondary; return d >= 0 ? AppColors.rise : AppColors.fall; } // ── Data loading ────────────────────────────────────────────────────────── Future _loadCurrent() async { if (_loadingCurrent) return; if (!mounted) return; setState(() { _loadingCurrent = true; _currentPage = 1; _currentHasMore = true; }); try { final orders = await ref.read(copyTradingRepositoryProvider).getTraderOrders( traderId: widget.traderId, type: 'current', page: 1, pageSize: _pageSize, ); if (mounted) setState(() { _currentOrders = orders; _currentLoaded = true; _currentHasMore = orders.length >= _pageSize; }); } catch (_) { if (mounted) setState(() => _currentLoaded = true); } finally { if (mounted) setState(() => _loadingCurrent = false); } } Future _loadMoreCurrent() async { if (!_currentHasMore || _currentLoadingMore || _loadingCurrent) return; final nextPage = _currentPage + 1; setState(() => _currentLoadingMore = true); try { final orders = await ref.read(copyTradingRepositoryProvider).getTraderOrders( traderId: widget.traderId, type: 'current', page: nextPage, pageSize: _pageSize, ); if (mounted) setState(() { _currentOrders = [..._currentOrders, ...orders]; _currentPage = nextPage; _currentHasMore = orders.length >= _pageSize; _currentLoadingMore = false; }); } catch (_) { if (mounted) setState(() => _currentLoadingMore = false); } } Future _loadHistory() async { if (_loadingHistory) return; setState(() { _loadingHistory = true; _historyPage = 1; _historyHasMore = true; }); try { final orders = await ref.read(copyTradingRepositoryProvider).getTraderOrders( traderId: widget.traderId, type: 'history', page: 1, pageSize: _pageSize, ); if (mounted) setState(() { _historyOrders = orders; _historyLoaded = true; _historyHasMore = orders.length >= _pageSize; }); } catch (_) { if (mounted) setState(() => _historyLoaded = true); } finally { if (mounted) setState(() => _loadingHistory = false); } } Future _loadMoreHistory() async { if (!_historyHasMore || _historyLoadingMore || _loadingHistory) return; final nextPage = _historyPage + 1; setState(() => _historyLoadingMore = true); try { final orders = await ref.read(copyTradingRepositoryProvider).getTraderOrders( traderId: widget.traderId, type: 'history', page: nextPage, pageSize: _pageSize, ); if (mounted) setState(() { _historyOrders = [..._historyOrders, ...orders]; _historyPage = nextPage; _historyHasMore = orders.length >= _pageSize; _historyLoadingMore = false; }); } catch (_) { if (mounted) setState(() => _historyLoadingMore = false); } } // ── Build ───────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { ref.watch(localeProvider); final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final async = ref.watch(_traderDetailProvider(widget.traderId)); ref.listen(_traderDetailProvider(widget.traderId), (_, next) { next.whenData((trader) { if (trader == null || _initialized) return; setState(() { _isFavorite = Trader.parseFavoriteFlag( trader['isFavorite'] ?? trader['isFavorited'] ?? trader['favorite'], ); _isFollowing = trader['isFollow']?.toString() == '1' || trader['follow']?.toString() == '1'; _initialized = true; }); }); }); final isTrader = ref.watch(copyTradingProvider.select((s) => s.isTrader)); final trader = async.valueOrNull; return Scaffold( backgroundColor: isDark ? AppColors.darkBg : AppColors.lightBgSecondary, appBar: AppBar( backgroundColor: isDark ? AppColors.darkBg : AppColors.lightBg, title: Text(AppLocalizations.of(context)!.traderDetail, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), actions: [ // 带单员不显示关注按钮 if (!isTrader) async.whenOrNull( data: (td) => td == null ? const SizedBox() : GestureDetector( onTap: () => _toggleFavorite(td), child: Padding( padding: const EdgeInsets.all(14), child: Icon( _isFavorite ? Icons.favorite : Icons.favorite_border, color: _isFavorite ? Colors.red : cs.onSurface.withAlpha(153), size: 24, ), ), ), ) ?? const SizedBox(), ], ), body: Column( children: [ Expanded( child: _buildContent(context, cs, isDark, async, trader, isTrader), ), // 带单员不显示跟单/取消按钮 if (!isTrader && trader != null) _BottomButton( isFollowing: _isFollowing, isFull: _isFull(trader), loading: _followLoading, onTap: () => _onFollowTap(context, trader), ), ], ), ); } bool _isFull(Map trader) { final following = int.tryParse(trader['following']?.toString() ?? '') ?? 0; final maxFollow = int.tryParse(trader['maxFollow']?.toString() ?? '') ?? 0; return !_isFollowing && maxFollow > 0 && following >= maxFollow; } Widget _buildContent( BuildContext context, ColorScheme cs, bool isDark, AsyncValue?> async, Map? trader, bool isTrader, ) { if (async.hasError && trader == null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(AppLocalizations.of(context)!.loadFailed, style: TextStyle(color: cs.onSurface.withAlpha(153))), const SizedBox(height: 12), ElevatedButton( onPressed: () => ref.invalidate(_traderDetailProvider(widget.traderId)), style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black), child: Text(AppLocalizations.of(context)!.retry), ), ], ), ); } if (async.isLoading && trader == null) { return const _TraderDetailSkeleton(); } final l10n = AppLocalizations.of(context)!; final tabBar = TabBar( controller: _tabController, labelColor: cs.onSurface, unselectedLabelColor: cs.onSurface.withAlpha(153), indicatorColor: AppColors.brand, indicatorWeight: 2.5, dividerColor: Colors.transparent, labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), unselectedLabelStyle: const TextStyle(fontSize: 14), tabs: [ Tab( text: '${l10n.currentCopyOrders}(${_currentOrders.length})'), Tab(text: l10n.historyCopyOrders), ], ); // NestedScrollView: 外层滚动(header)与内层滚动(各 tab 列表)完全独立, // 切换 tab 不会影响外层滚动位置,彻底解决回顶问题。 return NestedScrollView( controller: _scrollCtrl, headerSliverBuilder: (ctx, innerBoxIsScrolled) => [ SliverToBoxAdapter(child: _buildProfile(trader, cs, isDark)), SliverToBoxAdapter(child: _buildAccountInfo(trader, cs, isDark)), SliverToBoxAdapter(child: _buildCoreData(trader, cs, isDark)), SliverToBoxAdapter(child: _buildSymbolSection(cs, isDark, l10n)), SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(ctx), sliver: SliverPersistentHeader( pinned: true, delegate: _StickyTabBarDelegate( tabBar: tabBar, bgColor: isDark ? AppColors.darkBg : AppColors.lightBg, dividerColor: cs.outlineVariant.withAlpha(80), ), ), ), ], body: TabBarView( controller: _tabController, physics: const NeverScrollableScrollPhysics(), children: [ _OrderPage( orders: _currentOrders, loading: _loadingCurrent, loaded: _currentLoaded, hasMore: _currentHasMore, loadingMore: _currentLoadingMore, isHistory: false, cs: cs, onRefresh: () async { ref.invalidate(_traderDetailProvider(widget.traderId)); await _loadCurrent(); }, onLoadMore: _loadMoreCurrent, ), _OrderPage( orders: _historyOrders, loading: _loadingHistory, loaded: _historyLoaded, hasMore: _historyHasMore, loadingMore: _historyLoadingMore, isHistory: true, cs: cs, onRefresh: () async { ref.invalidate(_traderDetailProvider(widget.traderId)); await _loadHistory(); }, onLoadMore: _loadMoreHistory, ), ], ), ); } // ── Profile ─────────────────────────────────────────────────────────────── Widget _buildProfile( Map? trader, ColorScheme cs, bool isDark) { final nickname = trader?['nickname']?.toString() ?? ''; final description = trader?['description']?.toString() ?? ''; final tags = (trader?['tags'] as List?) ?.map((e) => e.toString()) .where((t) => t.isNotEmpty) .toList() ?? []; final following = trader?['following']?.toString() ?? '--'; final maxFollow = trader?['maxFollow']?.toString() ?? '--'; final registerDays = trader?['registerDays']?.toString() ?? '--'; final levelName = trader?['levelName']?.toString() ?? ''; final avatarUrl = trader != null ? resolvedAvatarUrlFromRecord(Map.from(trader)) : null; final letter = nickname.isNotEmpty ? nickname[0].toUpperCase() : '?'; return Container( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ _Avatar( letter: letter, avatarUrl: avatarUrl, bg: _avatarBg(nickname)), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 昵称 + 等级 badge Row( children: [ Flexible( child: Text(nickname, style: TextStyle( color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w700)), ), if (levelName.isNotEmpty) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(4), ), child: Text(levelName, style: const TextStyle( color: Colors.black, fontSize: 11, fontWeight: FontWeight.w700)), ), ], ], ), const SizedBox(height: 8), // 入驻天数 + 当前跟随 Row( children: [ Icon(Icons.calendar_today_outlined, size: 13, color: cs.onSurface.withAlpha(120)), const SizedBox(width: 3), Text( AppLocalizations.of(context)! .settledDaysLabelFmt(registerDays), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), const SizedBox(width: 16), Icon(Icons.group_outlined, size: 13, color: cs.onSurface.withAlpha(120)), const SizedBox(width: 3), Text( AppLocalizations.of(context)! .currentFollowingLabelFmt(following, maxFollow), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), ], ), ], ), ), ], ), if (description.isNotEmpty) ...[ const SizedBox(height: 12), Text(description, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), ], if (tags.isNotEmpty) ...[ const SizedBox(height: 10), Wrap( spacing: 6, runSpacing: 6, children: tags.map((t) => _TagChip(tag: t)).toList(), ), ], ], ), ); } // ── 账户信息 ────────────────────────────────────────────────────────────── Widget _buildAccountInfo( Map? trader, ColorScheme cs, bool isDark) { final l10n = AppLocalizations.of(context)!; return _StatCard( isDark: isDark, title: l10n.accountInfoTitle, rows: [ [ _DetailStat( label: l10n.cumFollowProfitAmtUsdt, value: _fmt(trader?['profitAmount']), valueColor: AppColors.rise, ), _DetailStat( label: l10n.fundStrengthUsdt, value: trader?['moneyStrength']?.toString() ?? '--', ), ], [ _DetailStat( label: l10n.cumFollowerCount, value: trader?['followCustomer']?.toString() ?? '--', ), _DetailStat( label: l10n.cumTradingDays, value: trader?['tradingDays']?.toString() ?? '--', ), ], ], ); } // ── 核心数据 ────────────────────────────────────────────────────────────── Widget _buildCoreData( Map? trader, ColorScheme cs, bool isDark) { final dayYield30 = trader?['dayYield14']; final profit30d = trader?['teamProfit14']; final winRate = trader?['winRate14'] ?? trader?['winRate30']; final dividendPercent = trader?['dividendPercent']; String profit30dStr; Color? profit30dColor; if (profit30d != null) { final d = double.tryParse(profit30d.toString()); profit30dStr = d == null ? '--' : '${d >= 0 ? '+' : ''}${d.toStringAsFixed(2)}'; profit30dColor = _pnlColor(profit30d); } else { profit30dStr = '--'; profit30dColor = null; } final l10n = AppLocalizations.of(context)!; return _StatCard( isDark: isDark, title: l10n.coreDataTitle, rows: [ [ _DetailStat( label: l10n.yield14d, value: _fmtPercent(dayYield30), valueColor: _pnlColor(dayYield30), ), _DetailStat( label: l10n.profit14dUsdt, value: profit30dStr, valueColor: profit30dColor, ), ], [ _DetailStat( label: l10n.winRate14d, value: winRate == null ? '--' : '${_fmt(winRate, decimals: 1)}%', ), _DetailStat( label: l10n.profitShareRatio, value: dividendPercent == null ? '--' : '${_fmt(dividendPercent, decimals: 0)}%', ), ], ], ); } // ── 带单合约 ────────────────────────────────────────────────────────────── Widget _buildSymbolSection(ColorScheme cs, bool isDark, AppLocalizations l10n) { final symbols = _traderSymbols; if (symbols.isEmpty) return const SizedBox.shrink(); return Container( margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => setState(() => _symbolExpanded = !_symbolExpanded), behavior: HitTestBehavior.opaque, child: Row( children: [ Text(l10n.tradingContracts, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)), const Spacer(), Icon( _symbolExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 20, ), ], ), ), if (_symbolExpanded) ...[ const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: symbols.map((sym) { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration( color: AppColors.brand.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), border: Border.all( color: AppColors.brand.withValues(alpha: 0.6), width: 1.5, ), ), child: Text( sym, style: const TextStyle( color: AppColors.brand, fontSize: 13, fontWeight: FontWeight.w600, ), ), ); }).toList(), ), ], ], ), ); } // ── Actions ─────────────────────────────────────────────────────────────── void _onFollowTap(BuildContext context, Map trader) { if (_isFollowing) { _toggleFollow(trader); } else { context.push('/follow-setting', extra: trader).then((result) { if (result == true && mounted) { setState(() => _isFollowing = true); ref.read(myCopyTradingProvider.notifier).silentRefresh(); ref.read(copyTradingProvider.notifier).silentRefresh(); } }); } } Future _toggleFavorite(Map trader) async { if (_favoriteLoading) return; if (!ref.read(isLoggedInProvider)) { context.push('/login'); return; } setState(() => _favoriteLoading = true); final repo = ref.read(copyTradingRepositoryProvider); final id = trader['id']?.toString() ?? widget.traderId; try { final ok = _isFavorite ? await repo.unfavoriteTrader(id) : await repo.favoriteTrader(id); if (mounted) { setState(() { if (ok) _isFavorite = !_isFavorite; _favoriteLoading = false; }); if (!ok) showTipDialog(context, content: AppLocalizations.of(context)!.operationFailedRetry); } } catch (e) { if (mounted) { setState(() => _favoriteLoading = false); showTipDialog(context, content: extractErrorMessage(e)); } } } Future _toggleFollow(Map trader) async { if (_followLoading) return; if (!ref.read(isLoggedInProvider)) { context.push('/login'); return; } final id = trader['id']?.toString() ?? widget.traderId; if (_isFollowing) { final confirmed = await showConfirmDialog(context, content: AppLocalizations.of(context)!.unfollowTraderConfirm); if (!confirmed || !mounted) return; } setState(() => _followLoading = true); final repo = ref.read(copyTradingRepositoryProvider); try { bool ok; if (_isFollowing) { ok = await repo.unfollowTrader(id); if (ok && mounted) { setState(() => _isFollowing = false); ref.read(myCopyTradingProvider.notifier).silentRefresh(); ref.read(copyTradingProvider.notifier).silentRefresh(); } } else { ok = await repo.followTrader({'traderId': id}); if (ok && mounted) setState(() => _isFollowing = true); } if (mounted) { setState(() => _followLoading = false); if (!ok) showTipDialog(context, content: AppLocalizations.of(context)!.operationFailedRetry); } } catch (e) { if (mounted) { setState(() => _followLoading = false); showTipDialog(context, content: extractErrorMessage(e)); } } } } // ── 订单列表页(每个 tab 独立滚动,切换不影响外层位置)───────────────────────────── class _OrderPage extends StatelessWidget { const _OrderPage({ required this.orders, required this.loading, required this.loaded, required this.hasMore, required this.loadingMore, required this.isHistory, required this.cs, required this.onRefresh, required this.onLoadMore, }); final List> orders; final bool loading; final bool loaded; final bool hasMore; final bool loadingMore; final bool isHistory; final ColorScheme cs; final Future Function() onRefresh; final VoidCallback onLoadMore; @override Widget build(BuildContext context) { return Builder( builder: (ctx) { return NotificationListener( onNotification: (n) { if (n is ScrollEndNotification && n.metrics.pixels >= n.metrics.maxScrollExtent - 300) { onLoadMore(); } return false; }, child: RefreshIndicator( color: AppColors.brand, onRefresh: onRefresh, child: CustomScrollView( slivers: [ SliverOverlapInjector( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(ctx), ), if (loading && !loaded) const SliverFillRemaining( child: Center( child: CircularProgressIndicator(color: AppColors.brand), ), ) else if (loaded && orders.isEmpty) SliverFillRemaining( child: Center( child: Text(AppLocalizations.of(context)!.noTradeRecords, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 14)), ), ) else SliverPadding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), sliver: SliverList( delegate: SliverChildBuilderDelegate( (_, i) { if (i < orders.length) { return isHistory ? _HistoryOrderCard(order: orders[i]) : _CurrentOrderCard(order: orders[i]); } if (loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: CircularProgressIndicator( color: AppColors.brand, strokeWidth: 2), ), ); } if (!hasMore && orders.isNotEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center( child: Text( AppLocalizations.of(context)!.noMore, style: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 12)), ), ); } return const SizedBox(height: 20); }, childCount: orders.length + 1, ), ), ), ], ), ), ); }, ); } } // ── Sticky TabBar delegate ──────────────────────────────────────────────────── class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate { const _StickyTabBarDelegate({ required this.tabBar, required this.bgColor, required this.dividerColor, }); final TabBar tabBar; final Color bgColor; final Color dividerColor; @override double get minExtent => tabBar.preferredSize.height; @override double get maxExtent => tabBar.preferredSize.height; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return DecoratedBox( decoration: BoxDecoration( color: bgColor, border: Border(bottom: BorderSide(color: dividerColor, width: 0.5)), ), child: tabBar, ); } @override bool shouldRebuild(covariant _StickyTabBarDelegate old) => old.tabBar != tabBar || old.bgColor != bgColor; } // ── 统计卡片 ────────────────────────────────────────────────────────────────── class _StatCard extends StatelessWidget { const _StatCard({ required this.isDark, required this.title, required this.rows, }); final bool isDark; final String title; final List> rows; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Container( margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)), ...rows.expand((row) => [ const SizedBox(height: 14), Row(children: row), ]), ], ), ); } } // ── Avatar ──────────────────────────────────────────────────────────────────── class _Avatar extends StatelessWidget { const _Avatar({required this.letter, required this.bg, this.avatarUrl}); final String letter; final Color bg; final String? avatarUrl; @override Widget build(BuildContext context) { if (avatarUrl != null && avatarUrl!.isNotEmpty) { return ClipOval( child: Image.network(avatarUrl!, width: 64, height: 64, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _fallback()), ); } return _fallback(); } Widget _fallback() => Container( width: 64, height: 64, decoration: BoxDecoration(color: bg, shape: BoxShape.circle), child: Center( child: Text(letter, style: const TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.w700)), ), ); } // ── 标签 chip ───────────────────────────────────────────────────────────────── class _TagChip extends StatelessWidget { const _TagChip({required this.tag}); final String tag; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: AppColors.tagBlueBg, borderRadius: BorderRadius.circular(20), ), child: Text(tag, style: const TextStyle(color: AppColors.tagBlue, fontSize: 12)), ); } } // ── 统计项 ──────────────────────────────────────────────────────────────────── class _DetailStat extends StatelessWidget { const _DetailStat( {required this.label, required this.value, this.valueColor}); final String label; final String value; final Color? valueColor; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)), const SizedBox(height: 4), Text( value, style: TextStyle( color: valueColor ?? cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ); } } // ── 数量格式化 ───────────────────────────────────────────────────────────────── String _fmtQty(dynamic raw) { if (raw == null) return '--'; final str = raw.toString().trim(); if (str.isEmpty || double.tryParse(str) == null) return '--'; final isNeg = str.startsWith('-'); final absStr = isNeg ? str.substring(1) : str; final dotIdx = absStr.indexOf('.'); String s; if (dotIdx < 0) { s = absStr; } else { final frac = absStr.substring(dotIdx + 1); s = '${absStr.substring(0, dotIdx)}.${frac.length >= 4 ? frac.substring(0, 4) : frac.padRight(4, '0')}'; } if (s.contains('.')) { s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); } final result = s.isEmpty ? '0' : s; return isNeg ? '-$result' : result; } // ── 当前带单卡片 ─────────────────────────────────────────────────────────────── class _CurrentOrderCard extends StatelessWidget { const _CurrentOrderCard({required this.order}); final Map order; String _fmt(dynamic raw, {int decimals = 2}) { if (raw == null) return '--'; final d = double.tryParse(raw.toString()); if (d == null) return '--'; return d.toStringAsFixed(decimals); } String _fmtPnl(dynamic raw, {int decimals = 2}) { if (raw == null) return '--'; final d = double.tryParse(raw.toString()); if (d == null) return '--'; return '${d >= 0 ? '+' : ''}${d.toStringAsFixed(decimals)}'; } Color _pnlColor(dynamic raw, ColorScheme cs) { final d = double.tryParse(raw?.toString() ?? ''); if (d == null) return cs.onSurface; return d >= 0 ? AppColors.rise : AppColors.fall; } String _fmtTimestamp(dynamic ts) { if (ts == null) return '--'; final ms = int.tryParse(ts.toString()); if (ms == null) return ts.toString(); final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true) .add(const Duration(hours: 8)); return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg; final symbol = order['symbol']?.toString() ?? '--'; final direction = order['direction']?.toString() ?? ''; final leverage = order['leverage']?.toString() ?? '--'; final marginType = order['positionType']?.toString() ?? order['marginType']?.toString() ?? l10n.crossMargin; final openPrice = order['openPrice'] ?? order['avgOpenPrice']; final currentPrice = order['currentPrice'] ?? order['markPrice']; final margin = order['principalAmount']; final quantity = order['totalPosition']; final roi = order['profitRate']; final pnl = order['profit']; final openTimeStr = _fmtTimestamp(order['openTime']); final positionId = order['positionId']?.toString() ?? order['id']?.toString() ?? ''; final isLong = direction == '0'; final directionLabel = isLong ? l10n.openLongBullish : l10n.openShortBearish; final directionColor = isLong ? AppColors.rise : AppColors.fall; final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol.replaceAll('USDT', ''); return Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: cardBg, borderRadius: BorderRadius.circular(10)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行 Row( children: [ Expanded( child: Text('$symbol ${l10n.perpetual}', style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)), ), _Badge( label: directionLabel, bgColor: directionColor.withValues(alpha: 0.15), textColor: directionColor), const SizedBox(width: 6), _Badge( label: '${leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)), const SizedBox(width: 6), _Badge( label: marginType, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)), ], ), const SizedBox(height: 12), Row( children: [ _OrderStat(label: l10n.openAvgPriceUsdt, value: _fmt(openPrice)), _OrderStat( label: l10n.currentPriceUsdt, value: _fmt(currentPrice)), _OrderStat(label: l10n.currentMarginUsdt, value: _fmt(margin)), ], ), const SizedBox(height: 10), Row( children: [ _OrderStat( label: l10n.qtyWithCoin(baseCoin), value: _fmtQty(quantity)), _OrderStat( label: l10n.returnRate, value: roi == null ? '--' : '${_fmtPnl(roi)}%', valueColor: _pnlColor(roi, cs)), _OrderStat( label: l10n.profitUsdt, value: _fmtPnl(pnl), valueColor: _pnlColor(pnl, cs)), ], ), const SizedBox(height: 10), Divider(height: 1, thickness: 0.5, color: cs.outlineVariant), const SizedBox(height: 8), Row( children: [ Text(l10n.openTimeWithValue(openTimeStr), style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const Spacer(), if (positionId.isNotEmpty) Row( children: [ Text('${l10n.positionIdPrefix}$positionId', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(width: 4), GestureDetector( onTap: () { Clipboard.setData(ClipboardData(text: positionId)); showTopToast(context, message: l10n.copyPositionIdSuccess, backgroundColor: AppColors.rise); }, child: Icon(Icons.copy_outlined, size: 13, color: cs.onSurface.withAlpha(100)), ), ], ), ], ), ], ), ); } } // ── 历史带单卡片 ─────────────────────────────────────────────────────────────── class _HistoryOrderCard extends StatelessWidget { const _HistoryOrderCard({required this.order}); final Map order; String _fmt(dynamic raw, {int decimals = 2}) { if (raw == null) return '--'; final d = double.tryParse(raw.toString()); if (d == null) return '--'; return d.toStringAsFixed(decimals); } String _fmtPnl(dynamic raw, {int decimals = 2}) { if (raw == null) return '--'; final d = double.tryParse(raw.toString()); if (d == null) return '--'; return '${d >= 0 ? '+' : ''}${d.toStringAsFixed(decimals)}'; } Color _pnlColor(dynamic raw, ColorScheme cs) { final d = double.tryParse(raw?.toString() ?? ''); if (d == null) return cs.onSurface; return d >= 0 ? AppColors.rise : AppColors.fall; } String _fmtTimestamp(dynamic ts) { if (ts == null) return '--'; final ms = int.tryParse(ts.toString()); if (ms == null) return ts.toString(); final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true) .add(const Duration(hours: 8)); return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; } void _showShareSheet(BuildContext context) { showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => _ShareOrderSheet(order: order, fmt: _fmt), ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg; final l10n = AppLocalizations.of(context)!; final symbol = order['symbol']?.toString() ?? '--'; final direction = order['direction']?.toString() ?? ''; final leverage = order['leverage']?.toString() ?? '--'; final marginType = order['positionType']?.toString() ?? order['marginType']?.toString() ?? l10n.crossMargin; final openPrice = order['openPrice'] ?? order['avgOpenPrice']; final closePrice = order['closePrice'] ?? order['avgClosePrice']; final quantity = order['totalPosition']; final pnl = order['profit']; final roi = order['profitRate']; final openTime = _fmtTimestamp(order['openTime']); final closeTime = _fmtTimestamp(order['closeTime']); final isLong = direction == '0'; final directionLabel = isLong ? l10n.openLongBullish : l10n.openShortBearish; final directionColor = isLong ? AppColors.rise : AppColors.fall; final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol.replaceAll('USDT', ''); return Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: cardBg, borderRadius: BorderRadius.circular(10)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行 Row( children: [ Expanded( child: Text('$symbol ${l10n.perpetual}', style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)), ), GestureDetector( onTap: () => _showShareSheet(context), child: Icon(Icons.open_in_new, size: 14, color: cs.onSurface.withAlpha(100)), ), ], ), const SizedBox(height: 8), // 徽章行 Row( children: [ _Badge( label: directionLabel, bgColor: directionColor.withValues(alpha: 0.15), textColor: directionColor), const SizedBox(width: 6), _Badge( label: '${leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)), const SizedBox(width: 6), _Badge( label: marginType, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)), ], ), const SizedBox(height: 12), // 数据行 1: 数量 + 收益 + 收益率 Row( children: [ _OrderStat( label: l10n.qtyWithCoin(baseCoin), value: _fmtQty(quantity)), _OrderStat( label: l10n.profitUsdt, value: _fmtPnl(pnl), valueColor: _pnlColor(pnl, cs)), _OrderStat( label: l10n.returnRate, value: roi == null ? '--' : '${_fmtPnl(roi)}%', valueColor: _pnlColor(roi, cs)), ], ), const SizedBox(height: 10), // 数据行 2: 开仓均价 + 平仓均价 Row( children: [ _OrderStat(label: l10n.openAvgPriceUsdt, value: _fmt(openPrice)), _OrderStat( label: l10n.closeAvgPriceUsdt, value: _fmt(closePrice)), const Expanded(child: SizedBox()), ], ), const SizedBox(height: 10), Divider(height: 1, thickness: 0.5, color: cs.outlineVariant), const SizedBox(height: 8), Row( children: [ Text(openTime, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const Spacer(), Text(closeTime, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), ], ), ], ), ); } } // ── Badge ───────────────────────────────────────────────────────────────────── class _Badge extends StatelessWidget { const _Badge({ required this.label, required this.bgColor, required this.textColor, this.borderColor, }); final String label; final Color bgColor; final Color textColor; final Color? borderColor; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(4), border: borderColor != null ? Border.all(color: borderColor!, width: 0.8) : null, ), child: Text(label, style: TextStyle( color: textColor, fontSize: 11, fontWeight: FontWeight.w600)), ); } } // ── OrderStat ───────────────────────────────────────────────────────────────── class _OrderStat extends StatelessWidget { const _OrderStat({required this.label, required this.value, this.valueColor}); final String label; final String value; final Color? valueColor; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)), const SizedBox(height: 2), Text(value, style: TextStyle( color: valueColor ?? cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], )), ], ), ); } } // ── 底部按钮 ────────────────────────────────────────────────────────────────── class _BottomButton extends StatelessWidget { const _BottomButton({ required this.isFollowing, required this.loading, required this.onTap, this.isFull = false, }); final bool isFollowing; final bool isFull; final bool loading; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return Container( padding: EdgeInsets.fromLTRB( 16, 12, 16, 12 + MediaQuery.of(context).padding.bottom), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, border: Border( top: BorderSide( color: isDark ? AppColors.darkDivider : AppColors.lightDivider)), ), child: SizedBox( width: double.infinity, height: 48, child: loading ? OutlinedButton( onPressed: null, style: OutlinedButton.styleFrom( side: BorderSide(color: cs.outline.withAlpha(50)), shape: const StadiumBorder()), child: const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), ) : isFull ? ElevatedButton( onPressed: null, style: ElevatedButton.styleFrom( backgroundColor: cs.outline.withAlpha(40), foregroundColor: cs.onSurface.withAlpha(100), elevation: 0, shape: const StadiumBorder(), disabledBackgroundColor: cs.outline.withAlpha(40), disabledForegroundColor: cs.onSurface.withAlpha(100), ), child: Text(AppLocalizations.of(context)!.fullCapacity, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600)), ) : isFollowing ? ElevatedButton( onPressed: onTap, style: ElevatedButton.styleFrom( backgroundColor: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, foregroundColor: cs.onSurface, elevation: 0, shape: const StadiumBorder(), ), child: Text(AppLocalizations.of(context)!.unfollow, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600)), ) : ElevatedButton( onPressed: onTap, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, shape: const StadiumBorder(), elevation: 0, ), child: Text(AppLocalizations.of(context)!.followTrader, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600)), ), ), ); } } // ── 骨架屏 ──────────────────────────────────────────────────────────────────── class _TraderDetailSkeleton extends StatelessWidget { const _TraderDetailSkeleton(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg; return AppShimmer( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Profile card Container( color: cardBg, padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ shimmerCircle(64), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(140, 18), const SizedBox(height: 10), shimmerBox(200, 13), ], ), ), ], ), ), const SizedBox(height: 8), // 账户信息 card Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: cardBg, borderRadius: BorderRadius.circular(12)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 14), const SizedBox(height: 14), Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(120, 12), const SizedBox(height: 6), shimmerBox(80, 15), ])), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(100, 12), const SizedBox(height: 6), shimmerBox(60, 15), ])), ]), const SizedBox(height: 14), Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(80, 12), const SizedBox(height: 6), shimmerBox(50, 15), ])), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(80, 12), const SizedBox(height: 6), shimmerBox(40, 15), ])), ]), ], ), ), const SizedBox(height: 8), // 核心数据 card Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: cardBg, borderRadius: BorderRadius.circular(12)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 14), const SizedBox(height: 14), Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(80, 12), const SizedBox(height: 6), shimmerBox(70, 15), ])), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(100, 12), const SizedBox(height: 6), shimmerBox(70, 15), ])), ]), ], ), ), const SizedBox(height: 16), // Tab bar skeleton Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(children: [ Expanded(child: shimmerFill(32, radius: 4)), const SizedBox(width: 16), Expanded(child: shimmerFill(32, radius: 4)), ]), ), const SizedBox(height: 16), // 订单卡片骨架 ...List.generate( 3, (_) => Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: cs.onSurface.withAlpha(8), borderRadius: BorderRadius.circular(10)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Expanded(child: shimmerBox(100, 14)), shimmerBox(60, 20, radius: 4), ]), const SizedBox(height: 10), Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.only(right: i < 2 ? 8.0 : 0), child: shimmerBox(double.infinity, 32, radius: 4), ), ))), ], ), ), ), ], ), ), ); } } // ── 分享带单 BottomSheet ─────────────────────────────────── class _ShareOrderSheet extends ConsumerStatefulWidget { const _ShareOrderSheet({required this.order, required this.fmt}); final Map order; final String Function(dynamic, {int decimals}) fmt; @override ConsumerState<_ShareOrderSheet> createState() => _ShareOrderSheetState(); } class _ShareOrderSheetState extends ConsumerState<_ShareOrderSheet> { final _cardKey = GlobalKey(); bool _sharing = false; bool _saving = false; String? _inviteCode; String? _inviteUrl; @override void initState() { super.initState(); _loadInviteInfo(); } Future _loadInviteInfo() async { try { final dio = ref.read(dioClientProvider); final data = await AuthService(dio).getMyInfo(); final prefix = data['promotionPrefix']?.toString() ?? ''; final code = data['promotionCode']?.toString() ?? ''; final url = (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null; if (mounted) { setState(() { _inviteCode = code.isNotEmpty ? code : null; _inviteUrl = url; }); } } catch (_) {} } String _fmtTimestamp(dynamic ts) { if (ts == null) return '--'; final ms = int.tryParse(ts.toString()); if (ms == null) return ts.toString(); final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true) .add(const Duration(hours: 8)); return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; } Future _renderCard() async { final boundary = _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null; final image = await boundary.toImage(pixelRatio: 3.0); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); return byteData?.buffer.asUint8List(); } Future _doSave(BuildContext context) async { setState(() => _saving = true); try { final bytes = await _renderCard(); if (bytes == null) return; await Gal.requestAccess(); await Gal.putImageBytes( bytes, name: 'trade_share_${DateTime.now().millisecondsSinceEpoch}', ); if (!context.mounted) return; showTopToast(context, message: AppLocalizations.of(context)!.saveSuccess, backgroundColor: AppColors.rise); } on GalException catch (e) { if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; if (e.type == GalExceptionType.accessDenied) { showTopToast(context, message: l10n.photoPermissionDenied, backgroundColor: AppColors.fall); } else { showTopToast(context, message: l10n.saveFailed, backgroundColor: AppColors.fall); } } catch (e) { if (context.mounted) { showTopToast(context, message: AppLocalizations.of(context)!.saveFailed, backgroundColor: AppColors.fall); } } finally { if (mounted) setState(() => _saving = false); } } Future _doShare(BuildContext context) async { setState(() => _sharing = true); try { final bytes = await _renderCard(); if (bytes == null) return; final tmpDir = await getTemporaryDirectory(); final file = File( '${tmpDir.path}/trade_share_${DateTime.now().millisecondsSinceEpoch}.png'); await file.writeAsBytes(bytes); if (!context.mounted) return; Navigator.of(context).pop(); await Share.shareXFiles( [XFile(file.path, mimeType: 'image/png')], subject: AppLocalizations.of(context)!.myTradingProfit, ); } catch (e) { if (context.mounted) { showTopToast(context, message: AppLocalizations.of(context)!.shareFailed, backgroundColor: AppColors.fall); } } finally { if (mounted) setState(() => _sharing = false); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final order = widget.order; final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0; final pnlPositive = profit >= 0; final l10n = AppLocalizations.of(context)!; final symbol = order['symbol']?.toString() ?? '--'; final isLong = (order['direction']?.toString() ?? '0') == '0'; final leverage = order['leverage']?.toString() ?? '--'; final profitRateRaw = double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0; final profitRateStr = '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%'; final openPrice = widget.fmt(order['openPrice']); final closePrice = widget.fmt(order['closePrice']); final openTime = _fmtTimestamp(order['openTime']); final closeTime = _fmtTimestamp(order['closeTime']); return Container( decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), ), padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 拖拽指示条 Container( width: 36, height: 4, decoration: BoxDecoration( color: cs.onSurface.withAlpha(60), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 16), // 分享卡片预览 RepaintBoundary( key: _cardKey, child: _TradeShareCard( symbol: symbol, isLong: isLong, leverage: leverage, profitRateStr: profitRateStr, pnlPositive: pnlPositive, openPrice: openPrice, closePrice: closePrice, openTime: openTime, closeTime: closeTime, inviteCode: _inviteCode, inviteUrl: _inviteUrl, ), ), const SizedBox(height: 24), // 操作按钮行:取消 | 保存海报 | 分享 Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), child: Text(l10n.cancelLabel, style: TextStyle(color: cs.onSurface, fontSize: 14)), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: _saving ? null : () => _doSave(context), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), child: _saving ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: cs.onSurface.withAlpha(153)), ) : Text(l10n.savePoster, style: TextStyle(color: cs.onSurface, fontSize: 14)), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton( onPressed: _sharing ? null : () => _doShare(context), style: ElevatedButton.styleFrom( backgroundColor: pnlPositive ? AppColors.rise : AppColors.fall, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: _sharing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : Text(l10n.shareLabel, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), ), ), ], ), ], ), ); } } // ── 带单分享卡片内容 ───────────────────────────────────────── class _TradeShareCard extends StatelessWidget { const _TradeShareCard({ required this.symbol, required this.isLong, required this.leverage, required this.profitRateStr, required this.pnlPositive, required this.openPrice, required this.closePrice, required this.openTime, required this.closeTime, this.inviteCode, this.inviteUrl, }); final String symbol; final bool isLong; final String leverage; final String profitRateStr; final bool pnlPositive; final String openPrice; final String closePrice; final String openTime; final String closeTime; final String? inviteCode; final String? inviteUrl; String _baseCoin(String sym) { if (sym.contains('/')) return sym.split('/').first; return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; final sideColor = isLong ? AppColors.rise : AppColors.fall; final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall; final coinSymbol = _baseCoin(symbol); // 主题色变量 final bgColors = isDark ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)] : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)]; final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E); final textSecondary = isDark ? Colors.white.withAlpha(120) : const Color(0xFF1A1F2E).withAlpha(120); final textMuted = isDark ? Colors.white.withAlpha(80) : const Color(0xFF1A1F2E).withAlpha(80); final borderColor = isDark ? Colors.white.withAlpha(40) : const Color(0xFF1A1F2E).withAlpha(30); final qrFgColor = isDark ? Colors.white : Colors.black; final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white; return Container( width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: bgColors, ), borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // LOGO + 品牌名 Row( children: [ Image.asset( 'assets/images/app_icon.png', height: 28, width: 28, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), const SizedBox(width: 8), Text( 'iBit', style: TextStyle( color: textPrimary, fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: 0.5), ), ], ), const SizedBox(height: 14), // 币对 + 永续 tag Row( children: [ Text( '${coinSymbol}USDT', style: TextStyle( color: textPrimary, fontSize: 22, fontWeight: FontWeight.w800), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: const Color(0xFFFFAB00), borderRadius: BorderRadius.circular(4), ), child: Text(l10n.perpetual, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700)), ), ], ), const SizedBox(height: 4), // 方向 + 杠杆 Text( '${isLong ? l10n.openLong : l10n.openShort} ${leverage}X', style: TextStyle( color: sideColor, fontSize: 15, fontWeight: FontWeight.w700), ), const SizedBox(height: 14), // 收益率(大字) Text(l10n.returnRate, style: TextStyle(color: textSecondary, fontSize: 12)), const SizedBox(height: 4), Text(profitRateStr, style: TextStyle( color: pnlColor, fontSize: 36, fontWeight: FontWeight.w800, letterSpacing: -0.5)), const SizedBox(height: 16), // 开仓均价 + 平仓均价 Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.openAvgPrice, style: TextStyle(color: textSecondary, fontSize: 11)), const SizedBox(height: 2), Text(openPrice, style: TextStyle( color: textPrimary, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(l10n.avgClosePrice, style: TextStyle(color: textSecondary, fontSize: 11)), const SizedBox(height: 2), Text(closePrice, style: TextStyle( color: textPrimary, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ), ], ), const SizedBox(height: 10), // 时间 Text(closeTime != '--' ? closeTime : openTime, style: TextStyle(color: textMuted, fontSize: 11)), const SizedBox(height: 14), // 分隔线 Divider(color: borderColor, height: 1), const SizedBox(height: 14), // 邀请码 + 二维码 Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (inviteCode != null) RichText( text: TextSpan( style: const TextStyle(fontSize: 15), children: [ TextSpan( text: l10n.inviteCodeLabel, style: TextStyle(color: textSecondary), ), TextSpan( text: inviteCode!, style: const TextStyle( color: AppColors.brand, fontWeight: FontWeight.w700), ), ], ), ), const SizedBox(height: 4), Text(l10n.registerAndEarnRebate, style: TextStyle(color: textMuted, fontSize: 12)), ], ), ), Container( decoration: BoxDecoration( border: Border.all(color: borderColor, width: 1), borderRadius: BorderRadius.circular(6), ), padding: const EdgeInsets.all(4), child: inviteUrl != null ? QrImageView( data: inviteUrl!, version: QrVersions.auto, size: 80, eyeStyle: QrEyeStyle( eyeShape: QrEyeShape.square, color: qrFgColor, ), dataModuleStyle: QrDataModuleStyle( dataModuleShape: QrDataModuleShape.square, color: qrFgColor, ), backgroundColor: qrBgColor, errorCorrectionLevel: QrErrorCorrectLevel.M, ) : const SizedBox(width: 80, height: 80), ), ], ), ], ), ), ); } }