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/l10n/app_localizations.dart'; import '../../../core/utils/avatar_urls.dart'; import '../../../core/network/dio_client.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/dialog_utils.dart' show extractErrorMessage; import '../../../core/utils/top_toast.dart'; import '../../../data/repositories/copy_trading_repository.dart'; import '../../../data/services/auth_service.dart'; import '../../../providers/app_provider.dart'; import '../../widgets/common/app_refresh_indicator.dart'; import '../../widgets/common/app_shimmer.dart'; import '../../widgets/common/app_tab_bar.dart'; class MyTradesScreen extends ConsumerStatefulWidget { const MyTradesScreen({super.key}); @override ConsumerState createState() => _MyTradesScreenState(); } class _MyTradesScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; late PageController _pageController; bool _loadingProfile = true; Map? _traderInfo; List> _tags = []; int? _followerCount; bool _loadingFollowers = true; bool _followersLoaded = false; List> _followers = []; int _followersPage = 1; bool _followersHasMore = true; bool _followersLoadingMore = false; static const _followersPageSize = 20; bool _loadingCurrentOrders = true; bool _currentOrdersLoaded = false; List> _currentOrders = []; bool _loadingHistoryOrders = true; bool _historyOrdersLoaded = false; List> _historyOrders = []; String _traderId = ''; List _traderSymbols = []; bool _symbolExpanded = true; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); _pageController = PageController(); _tabController.addListener(() { if (!context.mounted) return; if (_tabController.indexIsChanging) { _pageController.animateToPage( _tabController.index, duration: const Duration(milliseconds: 280), curve: Curves.easeOut, ); } else { _onTabChanged(_tabController.index); } }); _pageController.addListener(() { if (!context.mounted) return; if (!_pageController.hasClients) return; final page = _pageController.page!; final offset = page - _tabController.index; if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) { _tabController.offset = offset.clamp(-1.0, 1.0); } }); _loadProfile(); } @override void dispose() { _tabController.dispose(); _pageController.dispose(); super.dispose(); } Future _loadProfile() async { if (!context.mounted) return; setState(() => _loadingProfile = true); try { final repo = ref.read(copyTradingRepositoryProvider); // Step1: traderId 是后续请求的依赖,先串行获取 final followerInfo = await repo.getFollowerInfo(); _traderId = followerInfo?['id']?.toString() ?? ''; // Step2: 全部数据并行加载,skeleton 保持到所有数据就绪 final results = await Future.wait([ _traderId.isNotEmpty // [0] 带单员信息 ? repo.getTraderInfo(_traderId).then((v) => v) : Future.value(null), repo.getMyTags().then((v) => v), // [1] 标签 repo .getMyFollowerCount() // [2] 跟单人数 .then((v) => v) .catchError((_) => 0 as dynamic), repo .getMyFollowers(page: 1, pageSize: _followersPageSize) // [3] 跟单用户 .then((v) => v), _traderId.isNotEmpty // [4] 当前带单 + 合约持仓 ? Future.wait([ repo.getTraderOrders(traderId: _traderId, type: 'current'), repo.getFuturesPositions(), ]).then((v) => v) : Future.value(null), _traderId.isNotEmpty // [5] 历史带单 ? repo .getTraderOrders(traderId: _traderId, type: 'history') .then((v) => v) : Future.value(null), ]); final info = results[0] as Map?; final tags = results[1] as List>; final followerCount = results[2] as int; final followersList = results[3] as List>; // 当前带单:enriching with futures position data List> enrichedCurrentOrders = []; if (_traderId.isNotEmpty && results[4] != null) { final pair = results[4] as List; final currentRaw = pair[0] as List>; final futuresPos = pair[1] as List>; final futuresMap = >{ for (final p in futuresPos) if (p['id']?.toString().isNotEmpty == true) p['id'].toString(): p, }; enrichedCurrentOrders = currentRaw.map((o) { final pid = o['positionId']?.toString() ?? o['traderPositionId']?.toString() ?? ''; final fp = pid.isNotEmpty ? futuresMap[pid] : null; if (fp == null) return o; final coin = fp['coin'] as Map? ?? {}; final pricePrecision = (coin['coinScale'] as num?)?.toInt() ?? 2; return { ...o, 'marginRate': fp['marginRate'], if (fp['estimatedBlastPrice'] != null) 'estimatedBlastPrice': fp['estimatedBlastPrice'], '_pricePrecision': pricePrecision, }; }).toList(); } final historyOrders = _traderId.isNotEmpty && results[5] != null ? results[5] as List> : >[]; // 带单合约列表(与交易员详情页一致,标题走 l10n.tradingContracts) List traderSymbols = []; if (_traderId.isNotEmpty) { try { final symRaw = await repo.getTraderSymbols(_traderId); traderSymbols = symRaw .map((s) => s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '') .where((n) => n.isNotEmpty) .toList() ..sort(); } catch (_) {} } if (context.mounted) { setState(() { _traderInfo = info; _tags = tags; _followerCount = followerCount; // 跟单用户 _followers = followersList; _loadingFollowers = false; _followersLoaded = true; _followersHasMore = followersList.length >= _followersPageSize; // 当前带单 _currentOrders = enrichedCurrentOrders; _loadingCurrentOrders = false; _currentOrdersLoaded = true; // 历史带单 _historyOrders = historyOrders; _loadingHistoryOrders = false; _historyOrdersLoaded = true; _traderSymbols = traderSymbols; // 最后关掉骨架 _loadingProfile = false; }); } } catch (e) { if (context.mounted) setState(() => _loadingProfile = false); } } /// 仅刷新交易员信息卡(昵称/签名等),不重新加载订单列表 Future _refreshProfile() async { try { final repo = ref.read(copyTradingRepositoryProvider); final followerInfo = await repo.getFollowerInfo(); final traderId = followerInfo?['id']?.toString() ?? ''; if (traderId.isNotEmpty) { final info = await repo.getTraderInfo(traderId); List sym = []; try { final symRaw = await repo.getTraderSymbols(traderId); sym = symRaw .map((s) => s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '') .where((n) => n.isNotEmpty) .toList() ..sort(); } catch (_) {} if (context.mounted) { setState(() { _traderId = traderId; _traderInfo = info; _traderSymbols = sym; }); } } } catch (_) {} } void _onTabChanged(int index) { switch (index) { case 0: if (!_followersLoaded) _loadFollowers(); break; case 1: if (!_currentOrdersLoaded) _loadCurrentOrders(); break; case 2: if (!_historyOrdersLoaded) _loadHistoryOrders(); break; } } Future _loadFollowers() async { if (!context.mounted) return; setState(() { _loadingFollowers = true; _followersPage = 1; _followersHasMore = true; }); try { final list = await ref .read(copyTradingRepositoryProvider) .getMyFollowers(page: 1, pageSize: _followersPageSize); if (context.mounted) setState(() { _followers = list; _loadingFollowers = false; _followersLoaded = true; _followersHasMore = list.length >= _followersPageSize; }); } catch (_) { if (context.mounted) setState(() { _loadingFollowers = false; _followersLoaded = true; }); } } Future _loadMoreFollowers() async { if (!_followersHasMore || _followersLoadingMore || _loadingFollowers) return; final nextPage = _followersPage + 1; if (!context.mounted) return; setState(() => _followersLoadingMore = true); try { final list = await ref .read(copyTradingRepositoryProvider) .getMyFollowers(page: nextPage, pageSize: _followersPageSize); if (context.mounted) setState(() { _followers = [..._followers, ...list]; _followersPage = nextPage; _followersHasMore = list.length >= _followersPageSize; _followersLoadingMore = false; }); } catch (_) { if (context.mounted) setState(() => _followersLoadingMore = false); } } Future _loadCurrentOrders() async { if (_traderId.isEmpty) { if (context.mounted) setState(() { _loadingCurrentOrders = false; _currentOrdersLoaded = true; }); return; } if (!context.mounted) return; setState(() => _loadingCurrentOrders = true); try { final repo = ref.read(copyTradingRepositoryProvider); final results = await Future.wait([ repo.getTraderOrders(traderId: _traderId, type: 'current'), repo.getFuturesPositions(), ]); final list = results[0]; final futuresPositions = results[1]; // Build positionId → futures position map for cross-referencing final futuresMap = >{ for (final p in futuresPositions) if (p['id']?.toString().isNotEmpty == true) p['id'].toString(): p, }; // Enrich each copy order with marginRate from the matching futures position final enriched = list.map((o) { final pid = o['positionId']?.toString() ?? o['traderPositionId']?.toString() ?? ''; final fp = pid.isNotEmpty ? futuresMap[pid] : null; if (fp == null) return o; final coin = fp['coin'] as Map? ?? {}; final pricePrecision = (coin['coinScale'] as num?)?.toInt() ?? 2; return { ...o, 'marginRate': fp['marginRate'], if (fp['estimatedBlastPrice'] != null) 'estimatedBlastPrice': fp['estimatedBlastPrice'], '_pricePrecision': pricePrecision, }; }).toList(); if (context.mounted) { setState(() { _currentOrders = enriched; _loadingCurrentOrders = false; _currentOrdersLoaded = true; }); } } catch (_) { if (context.mounted) setState(() { _loadingCurrentOrders = false; _currentOrdersLoaded = true; }); } } Future _loadHistoryOrders() async { if (_traderId.isEmpty) { if (context.mounted) setState(() { _loadingHistoryOrders = false; _historyOrdersLoaded = true; }); return; } if (!context.mounted) return; setState(() => _loadingHistoryOrders = true); try { final list = await ref .read(copyTradingRepositoryProvider) .getTraderOrders(traderId: _traderId, type: 'history'); if (context.mounted) setState(() { _historyOrders = list; _loadingHistoryOrders = false; _historyOrdersLoaded = true; }); } catch (_) { if (context.mounted) setState(() { _loadingHistoryOrders = false; _historyOrdersLoaded = true; }); } } Future _removeFollower(String followId) async { final confirmed = await _showRemoveConfirmDialog(); if (!confirmed || !context.mounted) return; try { await ref.read(copyTradingRepositoryProvider).removeFollower(followId); if (context.mounted) { setState(() { _followers.removeWhere((f) => f['id']?.toString() == followId); if (_followerCount != null && _followerCount! > 0) { _followerCount = _followerCount! - 1; } }); showTopToast(context, message: AppLocalizations.of(context)!.removedSuccess, backgroundColor: AppColors.rise); } } catch (e) { if (context.mounted) { showTopToast(context, message: extractErrorMessage(e), backgroundColor: AppColors.fall); } } } Future _showRemoveConfirmDialog() async { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return await showDialog( context: context, builder: (ctx) => Dialog( backgroundColor: cs.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( l10n.confirmRemoveFollower, style: TextStyle( color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), ), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( l10n.removeFollowerMsg, style: TextStyle( color: cs.onSurface.withAlpha(180), fontSize: 14), textAlign: TextAlign.center, ), ), const SizedBox(height: 24), Divider( height: 1, thickness: 1, color: cs.outlineVariant.withAlpha(60)), // 两个按钮各占一半 IntrinsicHeight( child: Row( children: [ Expanded( child: GestureDetector( onTap: () => Navigator.of(ctx).pop(false), child: Container( height: 52, decoration: BoxDecoration( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(16)), ), alignment: Alignment.center, child: Text(l10n.cancelLabel, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 16)), ), ), ), VerticalDivider( width: 1, thickness: 1, color: cs.outlineVariant.withAlpha(60)), Expanded( child: GestureDetector( onTap: () => Navigator.of(ctx).pop(true), child: Container( height: 52, decoration: const BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.only( bottomRight: Radius.circular(16)), ), alignment: Alignment.center, child: Text(l10n.confirm, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)), ), ), ), ], ), ), ], ), ), ) ?? false; } /// 格式化数字,向下截断(不四舍五入,对应 Android RoundingMode.DOWN) String _fmt(dynamic v, {int decimals = 2}) { if (v == null) return '--'; final str = v.toString().trim(); if (str.isEmpty) return '--'; final d = double.tryParse(str); if (d == null) return str; final isNeg = str.startsWith('-'); final absStr = isNeg ? str.substring(1) : str; final dotIdx = absStr.indexOf('.'); String truncated; if (decimals == 0 || dotIdx < 0) { truncated = dotIdx < 0 ? absStr : absStr.substring(0, dotIdx); } else { final frac = absStr.substring(dotIdx + 1); truncated = '${absStr.substring(0, dotIdx)}.${frac.length >= decimals ? frac.substring(0, decimals) : frac.padRight(decimals, '0')}'; } final parts = truncated.split('.'); final intFmt = parts[0].replaceAllMapped( RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},'); final result = decimals > 0 ? '$intFmt.${parts.length > 1 ? parts[1] : '0' * decimals}' : intFmt; return isNeg ? '-$result' : result; } Widget _buildTradingContractsStrip(ColorScheme cs) { if (_traderSymbols.isEmpty) { return const SizedBox.shrink(); } final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; return Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), 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: _traderSymbols.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(), ), ], ], ), ); } @override Widget build(BuildContext context) { ref.watch(localeProvider); final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back_ios, size: 18), onPressed: () => context.pop(), ), title: Text(l10n.myTrades, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), actions: [ IconButton( icon: Icon(Icons.settings_outlined, color: cs.onSurface), onPressed: () => context.push('/trader-settings').then((_) { if (context.mounted) _refreshProfile(); }), ), ], ), body: _loadingProfile ? const _MyTradesFullSkeleton() : Column( children: [ // ── 交易员信息卡 ────────────────────────── _ProfileCard(traderInfo: _traderInfo, fmt: _fmt, tags: _tags), _buildTradingContractsStrip(cs), // ── Tab 栏 ──────────────────────────────── Container( decoration: BoxDecoration( border: Border( bottom: BorderSide( color: cs.outlineVariant.withAlpha(60), width: 1)), ), child: TabBar( controller: _tabController, indicator: StretchTabIndicator( controller: _tabController, color: AppColors.brand, ), indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, tabs: [ Tab( text: (_followerCount != null && _followerCount! > 0) ? '${l10n.followersTab}($_followerCount)' : l10n.followersTab), Tab(text: l10n.currentCopyOrders), Tab(text: l10n.historyCopyOrders), ], ), ), // ── Tab 内容(PageView 支持左右滑动)───────────── Expanded( child: PageView( controller: _pageController, physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), onPageChanged: (index) { if (_tabController.indexIsChanging) return; _tabController.index = index; }, children: [ _FollowersTab( loading: _loadingFollowers, loaded: _followersLoaded, followers: _followers, hasMore: _followersHasMore, loadingMore: _followersLoadingMore, onRemove: _removeFollower, fmt: _fmt, onRefresh: _loadFollowers, onLoadMore: _loadMoreFollowers, ), _OrdersTab( loading: _loadingCurrentOrders, loaded: _currentOrdersLoaded, orders: _currentOrders, onLoad: _loadCurrentOrders, fmt: _fmt, onRefresh: _loadCurrentOrders, ), _OrdersTab( loading: _loadingHistoryOrders, loaded: _historyOrdersLoaded, orders: _historyOrders, onLoad: _loadHistoryOrders, fmt: _fmt, isHistory: true, onRefresh: _loadHistoryOrders, ), ], ), ), ], ), ); } } // ── 交易员信息卡 ────────────────────────────────────────── class _ProfileCard extends StatelessWidget { const _ProfileCard( {required this.traderInfo, required this.fmt, this.tags = const []}); final Map? traderInfo; final String Function(dynamic, {int decimals}) fmt; final List> tags; String get _nickname => traderInfo?['nickname']?.toString() ?? '--'; String get _description => traderInfo?['description']?.toString() ?? ''; String get _levelName => traderInfo?['levelName']?.toString() ?? ''; String? get _avatarUrl => traderInfo == null ? null : resolvedAvatarUrlFromRecord(Map.from(traderInfo!)); String get _followingCurrent => traderInfo?['following']?.toString() ?? '--'; String get _followingMax => traderInfo?['maxFollow']?.toString() ?? '--'; String get _joinDays => traderInfo?['registerDays']?.toString() ?? traderInfo?['settledDays']?.toString() ?? '--'; String get _moneyStrength => traderInfo?['moneyStrength']?.toString() ?? '--'; String get _cumulativeProfit => fmt(traderInfo?['profitAmount'] ?? traderInfo?['totalFollowProfit']); String get _cumulativeFollowers => traderInfo?['followCustomer']?.toString() ?? '--'; String get _totalTradeDays => traderInfo?['tradingDays']?.toString() ?? '--'; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final letter = _nickname.isNotEmpty ? _nickname[0].toUpperCase() : 'T'; return Container( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头像 + 昵称 + 描述 Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Avatar( letter: letter, levelName: _levelName, avatarUrl: _avatarUrl), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_nickname, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)), if (_description.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( _description, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), if (tags.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: tags.map((tag) { final name = tag['name']?.toString() ?? ''; if (name.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(right: 6), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 3), decoration: BoxDecoration( color: AppColors.tagBlueBg, borderRadius: BorderRadius.circular(20), ), child: Text( name, style: const TextStyle( color: AppColors.tagBlue, fontSize: 12), ), ), ); }).toList(), ), ), ), ], ), ), ], ), const SizedBox(height: 16), // 统计网格:浅灰色背景圆角卡片(无边框) Builder(builder: (context) { final l10n = AppLocalizations.of(context)!; return Container( padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), decoration: BoxDecoration( color: cs.onSurface.withAlpha(10), borderRadius: BorderRadius.circular(10), ), child: Column( children: [ // 第1行 Row( children: [ _StatCell( label: l10n.currentFollowersLabel, value: _followingCurrent, valueSuffix: ' / $_followingMax', ), _StatCell( label: l10n.settledDaysTitle, value: _joinDays, alignCenter: true), _StatCell( label: l10n.fundStrength, value: '$_moneyStrength USDT', alignEnd: true), ], ), const SizedBox(height: 12), // 第2行 Row( children: [ _StatCell( label: l10n.cumCopyProfitUsdt, value: _cumulativeProfit), _StatCell( label: l10n.cumFollowerCount, value: _cumulativeFollowers, alignCenter: true), _StatCell( label: l10n.cumTradingDays, value: _totalTradeDays, alignEnd: true), ], ), ], ), ); }), ], ), ); } } class _Avatar extends StatelessWidget { const _Avatar( {required this.letter, required this.levelName, this.avatarUrl}); final String letter; final String levelName; final String? avatarUrl; @override Widget build(BuildContext context) { final hasAvatar = avatarUrl != null && avatarUrl!.isNotEmpty; return SizedBox( width: 54, height: 64, child: Stack( alignment: Alignment.topCenter, children: [ if (hasAvatar) ClipOval( child: Image.network( avatarUrl!, width: 54, height: 54, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _LetterAvatar(letter: letter), ), ) else _LetterAvatar(letter: letter), if (levelName.isNotEmpty) Positioned( bottom: 0, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(10), ), child: Text(levelName, style: const TextStyle( color: Colors.black, fontSize: 10, fontWeight: FontWeight.w700)), ), ), ], ), ); } } class _LetterAvatar extends StatelessWidget { const _LetterAvatar({required this.letter}); final String letter; @override Widget build(BuildContext context) { return Container( width: 54, height: 54, decoration: const BoxDecoration(color: Color(0xFF5B7BE8), shape: BoxShape.circle), child: Center( child: Text(letter, style: const TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700))), ); } } class _StatCell extends StatelessWidget { const _StatCell( {required this.label, required this.value, this.valueSuffix, this.alignEnd = false, this.alignCenter = false}); final String label; final String value; final String? valueSuffix; // 灰色后缀,如 " / 300" final bool alignEnd; final bool alignCenter; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final align = alignEnd ? CrossAxisAlignment.end : alignCenter ? CrossAxisAlignment.center : CrossAxisAlignment.start; final greyColor = cs.onSurface.withAlpha(120); return Expanded( child: Column( crossAxisAlignment: align, children: [ Text(label, style: TextStyle(color: greyColor, fontSize: 11)), const SizedBox(height: 3), valueSuffix != null ? RichText( text: TextSpan( style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: cs.onSurface), children: [ TextSpan(text: value), TextSpan( text: valueSuffix, style: TextStyle( color: greyColor, fontWeight: FontWeight.w400)), ], ), ) : Text(value, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)), ], ), ); } } // ── 跟单用户 Tab ────────────────────────────────────────── class _FollowersTab extends StatelessWidget { const _FollowersTab({ required this.loading, required this.loaded, required this.followers, required this.hasMore, required this.loadingMore, required this.onRemove, required this.fmt, required this.onRefresh, required this.onLoadMore, }); final bool loading; final bool loaded; final List> followers; final bool hasMore; final bool loadingMore; final void Function(String id) onRemove; final String Function(dynamic, {int decimals}) fmt; final Future Function() onRefresh; final VoidCallback onLoadMore; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isLoading = !loaded || (loading && followers.isEmpty); return NotificationListener( onNotification: (n) { if (!isLoading && n is ScrollEndNotification && n.metrics.pixels >= n.metrics.maxScrollExtent - 200) { onLoadMore(); } return false; }, child: AppRefreshIndicator( onRefresh: onRefresh, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 16), itemCount: isLoading ? 4 : (followers.isEmpty ? 1 : followers.length + 1), itemBuilder: (_, i) { if (isLoading) return const _FollowerCardSkeleton(); if (followers.isEmpty) { return SizedBox( height: 200, child: Center( child: Text(AppLocalizations.of(context)!.noFollowers, style: TextStyle(color: cs.onSurface.withAlpha(100))), ), ); } if (i >= followers.length) { if (loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: CircularProgressIndicator( color: AppColors.brand, strokeWidth: 2)), ); } if (!hasMore) { 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: 16); } return _FollowerCard( follower: followers[i], onRemove: onRemove, fmt: fmt); }, ), ), ); } } class _FollowerCard extends StatelessWidget { const _FollowerCard( {required this.follower, required this.onRemove, required this.fmt}); final Map follower; final void Function(String) onRemove; final String Function(dynamic, {int decimals}) fmt; static const _avatarColors = [ Color(0xFF5B7BE8), Color(0xFFf7931a), Color(0xFF9945ff), Color(0xFFf3ba2f), Color(0xFF2775ca), Color(0xFF00aae4), ]; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final uid = follower['id']?.toString() ?? ''; final nickname = follower['nickname']?.toString() ?? uid; final display = nickname.isNotEmpty ? nickname : uid; final colorIdx = uid.isNotEmpty ? uid.codeUnitAt(0) % _avatarColors.length : 0; final letter = display.isNotEmpty ? display[0].toUpperCase() : '?'; // 跟随人数 final following = follower['following']?.toString(); final maxFollow = follower['maxFollow']?.toString(); final hasFollowInfo = following != null || maxFollow != null; return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(15), blurRadius: 8, offset: const Offset(0, 2)), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头部:头像 + 昵称+跟随人数 + 移除按钮 Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: _avatarColors[colorIdx], shape: BoxShape.circle), child: Center( child: Text(letter, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( display.isNotEmpty ? display : AppLocalizations.of(context)!.copyUser, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600), ), if (hasFollowInfo) ...[ const SizedBox(height: 4), Row( children: [ Icon(Icons.people_outline, size: 13, color: cs.onSurface.withAlpha(120)), const SizedBox(width: 4), Text( maxFollow != null ? AppLocalizations.of(context)! .followersMaxLabel( following ?? '--', maxFollow) : AppLocalizations.of(context)! .followersCountLabel(following ?? '--'), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12), ), ], ), ], ], ), ), const SizedBox(width: 8), OutlinedButton( onPressed: () => onRemove(uid), style: OutlinedButton.styleFrom( side: BorderSide(color: cs.onSurface, width: 1.5), foregroundColor: cs.onSurface, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20)), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text(AppLocalizations.of(context)!.remove, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500)), ), ], ), const SizedBox(height: 12), Divider( height: 1, thickness: 0.5, color: cs.outlineVariant.withAlpha(80)), const SizedBox(height: 12), // 底部统计行(含竖向分隔线) Builder(builder: (context) { final l10n = AppLocalizations.of(context)!; return IntrinsicHeight( child: Row( children: [ _FollowerStat( label: l10n.accountEquityUsdt, value: fmt(follower['balance'] ?? follower['totalBalance'])), VerticalDivider( width: 1, thickness: 0.8, color: Colors.grey.withAlpha(100)), _FollowerStat( label: l10n.cumProfitShareUsdt, value: fmt(follower['totalProfitSharing']), alignCenter: true), VerticalDivider( width: 1, thickness: 0.8, color: Colors.grey.withAlpha(100)), _FollowerStat( label: l10n.lastProfitShare, value: fmt(follower['lastProfitSharing']), alignEnd: true), ], ), ); }), // 跟随时间 Builder(builder: (context) { final followTime = follower['followTime']?.toString() ?? ''; if (followTime.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 8), child: Row( children: [ Icon(Icons.access_time, size: 12, color: cs.onSurface.withAlpha(100)), const SizedBox(width: 4), Text( '${AppLocalizations.of(context)!.followerFollowTime}:$followTime', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12), ), ], ), ); }), ], ), ); } } class _FollowerStat extends StatelessWidget { const _FollowerStat( {required this.label, required this.value, this.alignEnd = false, this.alignCenter = false}); final String label; final String value; final bool alignEnd; final bool alignCenter; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final align = alignEnd ? CrossAxisAlignment.end : alignCenter ? CrossAxisAlignment.center : CrossAxisAlignment.start; return Expanded( child: Column( crossAxisAlignment: align, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(value, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ); } } // ── 带单仓位 Tab ────────────────────────────────────────── class _OrdersTab extends StatelessWidget { const _OrdersTab({ required this.loading, required this.loaded, required this.orders, required this.onLoad, required this.fmt, required this.onRefresh, this.isHistory = false, }); final bool loading; final bool loaded; final List> orders; final VoidCallback onLoad; final String Function(dynamic, {int decimals}) fmt; final Future Function() onRefresh; final bool isHistory; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isLoading = !loaded || (loading && orders.isEmpty); return AppRefreshIndicator( onRefresh: onRefresh, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 16), itemCount: isLoading ? 4 : (orders.isEmpty ? 1 : orders.length), itemBuilder: (_, i) { if (isLoading) return const _OrderCardSkeleton(); if (orders.isEmpty) { return SizedBox( height: 200, child: Center( child: Text( isHistory ? AppLocalizations.of(context)!.noHistoryTrades : AppLocalizations.of(context)!.noCurrentTrades, style: TextStyle(color: cs.onSurface.withAlpha(100)), ), ), ); } if (i < 0 || i >= orders.length) return const SizedBox.shrink(); return _OrderCard(order: orders[i], fmt: fmt, isHistory: isHistory); }, ), ); } } class _OrderCard extends StatelessWidget { const _OrderCard( {required this.order, required this.fmt, this.isHistory = false}); final Map order; final String Function(dynamic, {int decimals}) fmt; final bool isHistory; /// 按交易对价格精度截断(RoundingMode.DOWN),去除尾部零,与安卓 coinScale 逻辑一致 String _fmtWithScale(double v, int scale) { if (scale < 0) scale = 0; final factor = scale == 0 ? 1.0 : List.generate(scale, (_) => 10).fold(1.0, (a, b) => a * b); final truncated = v >= 0 ? (v * factor).floorToDouble() / factor : (v * factor).ceilToDouble() / factor; String s = truncated.toStringAsFixed(scale); if (s.contains('.')) { s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); } return s.isEmpty ? '0' : s; } String _fmtTimestamp(dynamic ts) { if (ts == null) return '--'; final ms = int.tryParse(ts.toString()); if (ms == null) return ts.toString(); // 后台时间戳为 UTC 毫秒,统一转为 UTC+8(北京时间)展示 final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true) .add(const Duration(hours: 8)); final y = dt.year; final mo = dt.month.toString().padLeft(2, '0'); final d = dt.day.toString().padLeft(2, '0'); final h = dt.hour.toString().padLeft(2, '0'); final mi = dt.minute.toString().padLeft(2, '0'); final s = dt.second.toString().padLeft(2, '0'); return '$y-$mo-$d $h:$mi:$s'; } /// 数量字段专用:4位小数、向下截断、去尾零(对应 Android textDigital=4 + stripTrailingZeros) String _fmtQty(dynamic v) { final s = fmt(v, decimals: 4); if (s == '--' || !s.contains('.')) return s; return s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); } @override Widget build(BuildContext context) { return isHistory ? _buildHistory(context) : _buildCurrent(context); } // ── 当前带单卡片 ──────────────────────────────────────── Widget _buildCurrent(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; final symbol = order['symbol']?.toString() ?? '--'; // 提取基础币种:BTC/USDT → BTC final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol; final isLong = (order['direction']?.toString() ?? '0') == '0'; final leverage = order['leverage']?.toString() ?? '--'; final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0; final profitColor = profit >= 0 ? AppColors.rise : AppColors.fall; final profitRateRaw = double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0; final profitRateStr = '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%'; final profitRateColor = profitRateRaw >= 0 ? AppColors.rise : AppColors.fall; final openTime = _fmtTimestamp(order['openTime']); final positionId = order['positionId']?.toString() ?? order['traderPositionId']?.toString() ?? '--'; // ── 保证金比率:参照合约持仓页公式 principalAmount / (totalPosition * currentPrice) * 100 // 若 API 直接返回 marginRate 则优先使用 final principalAmount = double.tryParse(order['principalAmount']?.toString() ?? '0') ?? 0.0; final apiMarginRate = double.tryParse(order['marginRate']?.toString() ?? ''); final currentPrice = double.tryParse(order['currentPrice']?.toString() ?? '0') ?? 0.0; final qty = double.tryParse(order['totalPosition']?.toString() ?? '0') ?? 0.0; String marginRatioStr; if (apiMarginRate != null && apiMarginRate > 0) { marginRatioStr = '${apiMarginRate.toStringAsFixed(2)}%'; } else if (qty > 0 && currentPrice > 0) { marginRatioStr = '${(principalAmount / (qty * currentPrice) * 100).toStringAsFixed(2)}%'; } else { marginRatioStr = '--'; } // ── 强平价格:按交易对价格精度(coinScale)截断显示,与安卓合约持仓页保持一致 final pricePrecision = (order['_pricePrecision'] as int?) ?? 2; String liquidationPriceStr = '--'; final blastVal = double.tryParse(order['estimatedBlastPrice']?.toString() ?? ''); if (blastVal != null && blastVal > 0) { liquidationPriceStr = _fmtWithScale(blastVal, pricePrecision); } return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(12), border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行 Row( children: [ Text(symbol, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)), const SizedBox(width: 4), Text(l10n.perpetual, style: TextStyle(color: cs.onSurface, fontSize: 13)), const SizedBox(width: 8), _Badge( text: isLong ? l10n.openLong : l10n.openShort, color: isLong ? AppColors.rise : AppColors.fall), const SizedBox(width: 6), _Badge( text: l10n.crossMargin, color: cs.onSurface.withAlpha(120)), const SizedBox(width: 6), _Badge(text: '${leverage}X', color: cs.onSurface.withAlpha(120)), ], ), const SizedBox(height: 12), // 未实现盈亏(大字)+ 收益率 Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.unrealizedPnlUsdt, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 3), Text('${profit >= 0 ? '+' : ''}${fmt(profit)}', style: TextStyle( color: profitColor, fontSize: 22, fontWeight: FontWeight.w700)), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(l10n.returnRate, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 3), Text(profitRateStr, style: TextStyle( color: profitRateColor, fontSize: 14, fontWeight: FontWeight.w600)), ], ), ], ), const SizedBox(height: 12), const Divider(height: 1), const SizedBox(height: 10), Row( children: [ _OrderStat( label: l10n.positionSizeWithCoin(baseCoin), value: _fmtQty(order['totalPosition'])), _OrderStat( label: l10n.marginUsdt, value: fmt(principalAmount), alignCenter: true), _OrderStat( label: l10n.marginRatio, value: marginRatioStr, alignEnd: true), ], ), const SizedBox(height: 10), Row( children: [ _OrderStat( label: l10n.openAvgPriceUsdt, value: fmt(order['openPrice'])), _OrderStat( label: l10n.currentPriceUsdt, value: fmt(order['currentPrice']), alignCenter: true), _OrderStat( label: l10n.liqPriceUsdt, value: liquidationPriceStr, alignEnd: true), ], ), const SizedBox(height: 10), const Divider(height: 1), const SizedBox(height: 8), Row( children: [ Expanded( child: Text(l10n.openTimeWithValue(openTime), style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), ), GestureDetector( onTap: () { Clipboard.setData(ClipboardData(text: positionId)); showTopToast(context, message: l10n.positionIdCopied, backgroundColor: AppColors.rise); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('${l10n.positionIdPrefix}$positionId', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(width: 4), Icon(Icons.content_copy, size: 14, color: cs.onSurface.withAlpha(120)), ], ), ), ), ], ), ], ), ); } // ── 历史带单卡片 ──────────────────────────────────────── Widget _buildHistory(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; final symbol = order['symbol']?.toString() ?? '--'; final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol; final isLong = (order['direction']?.toString() ?? '0') == '0'; final leverage = order['leverage']?.toString() ?? '--'; final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0; final profitColor = profit >= 0 ? AppColors.rise : AppColors.fall; final profitRateRaw = double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0; final profitRateStr = '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%'; final profitRateColor = profitRateRaw >= 0 ? AppColors.rise : AppColors.fall; final openTime = _fmtTimestamp(order['openTime']); final closeTime = _fmtTimestamp(order['closeTime']); final headerBg = cs.onSurface.withAlpha(22); final bodyBg = cs.onSurface.withAlpha(8); final dividerColor = cs.outlineVariant.withAlpha(80); return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(12), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── 第一部分:深灰色标题行 ───────────────────────── Container( color: headerBg, padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), child: Row( children: [ Text(symbol, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)), const SizedBox(width: 4), Text(l10n.perpetual, style: TextStyle(color: cs.onSurface, fontSize: 13)), const SizedBox(width: 8), _Badge( text: isLong ? l10n.openLong : l10n.openShort, color: isLong ? AppColors.rise : AppColors.fall), const SizedBox(width: 6), _Badge( text: l10n.crossMargin, color: cs.onSurface.withAlpha(120)), const SizedBox(width: 6), _Badge( text: '${leverage}X', color: cs.onSurface.withAlpha(120)), const Spacer(), GestureDetector( onTap: () => _showShareSheet(context), child: Icon(Icons.share_outlined, size: 18, color: cs.onSurface.withAlpha(120)), ), ], ), ), // ── 第二部分:灰色 — 平仓数量 / 已实现盈亏 / 收益率 ── Container( color: bodyBg, padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), child: Row( children: [ _OrderStat( label: l10n.closeSizeWithCoin(baseCoin), value: _fmtQty(order['totalPosition'])), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.realizedPnlUsdt, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text( '${profit >= 0 ? '+' : ''}${_fmtQty(order['profit'])}', style: TextStyle( color: profitColor, fontSize: 15, fontWeight: FontWeight.w700)), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(l10n.returnRate, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(profitRateStr, style: TextStyle( color: profitRateColor, fontSize: 14, fontWeight: FontWeight.w600)), ], ), ], ), ), // ── 分割线 ───────────────────────────────────────── Divider(height: 0.5, thickness: 0.5, color: dividerColor), // ── 第三部分:灰色 — 开仓均价 / 平仓均价 ───────────── Container( color: bodyBg, padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.openAvgPriceUsdt, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(fmt(order['openPrice']), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(openTime, style: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 11)), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(l10n.closeAvgPriceUsdt, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(fmt(order['closePrice']), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(closeTime, style: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 11)), ], ), ), ], ), ), ], ), ), ); } void _showShareSheet(BuildContext context) { showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => _ShareOrderSheet(order: order, fmt: fmt), ); } } // ── 骨架屏 ──────────────────────────────────────────────── /// 「我的带单」首次加载时的全页骨架(交易员信息卡 + Tab 栏) class _MyTradesFullSkeleton extends StatelessWidget { const _MyTradesFullSkeleton(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return Column( children: [ // 交易员信息卡骨架 AppShimmer( child: Container( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerCircle(54), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(120, 16), const SizedBox(height: 8), shimmerBox(200, 13), ], ), ), ], ), const SizedBox(height: 16), Container( padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), decoration: BoxDecoration( color: cs.onSurface.withAlpha(10), borderRadius: BorderRadius.circular(10), ), child: Column( children: [ Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: i == 1 ? 8.0 : 0), child: Column( crossAxisAlignment: i == 0 ? CrossAxisAlignment.start : i == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.end, children: [ shimmerBox(55, 11), const SizedBox(height: 5), shimmerBox(40, 14), ], ), ), ))), const SizedBox(height: 12), Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: i == 1 ? 8.0 : 0), child: Column( crossAxisAlignment: i == 0 ? CrossAxisAlignment.start : i == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.end, children: [ shimmerBox(55, 11), const SizedBox(height: 5), shimmerBox(40, 14), ], ), ), ))), ], ), ), ], ), ), ), // Tab 栏骨架 AppShimmer( child: Container( decoration: BoxDecoration( border: Border( bottom: BorderSide( color: cs.outlineVariant.withAlpha(60), width: 1)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: i == 1 ? 8.0 : 0), child: shimmerFill(16, radius: 4), ), )), ), ), ), ), // 列表骨架 Expanded( child: ListView.builder( padding: const EdgeInsets.only(bottom: 16), itemCount: 4, itemBuilder: (_, __) => const _FollowerCardSkeleton(), ), ), ], ); } } /// 跟单用户卡片骨架(对应 _FollowerCard 样式) class _FollowerCardSkeleton extends StatelessWidget { const _FollowerCardSkeleton(); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return AppShimmer( child: Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, borderRadius: BorderRadius.circular(12), ), child: Column( children: [ Row( children: [ shimmerCircle(40), const SizedBox(width: 10), Expanded(child: shimmerBox(100, 14)), shimmerBox(55, 32, radius: 20), ], ), const SizedBox(height: 10), Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: i == 1 ? 8.0 : 0), child: Column( crossAxisAlignment: i == 0 ? CrossAxisAlignment.start : i == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.end, children: [ shimmerBox(50, 11), const SizedBox(height: 4), shimmerBox(40, 13), ], ), ), ))), const SizedBox(height: 8), Row(children: [ shimmerBox(16, 12), const SizedBox(width: 4), shimmerBox(120, 12) ]), ], ), ), ); } } /// 带单仓位卡片骨架(对应 _OrderCard 当前带单样式) class _OrderCardSkeleton extends StatelessWidget { const _OrderCardSkeleton(); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return AppShimmer( child: Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(12), border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行:交易对 + badges Row(children: [ shimmerBox(80, 15), const SizedBox(width: 8), shimmerBox(40, 20, radius: 4), const SizedBox(width: 6), shimmerBox(30, 20, radius: 4), const SizedBox(width: 6), shimmerBox(35, 20, radius: 4), ]), const SizedBox(height: 12), // 未实现盈亏 Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(90, 11), const SizedBox(height: 5), shimmerBox(120, 22), ])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ shimmerBox(45, 11), const SizedBox(height: 5), shimmerBox(70, 14), ]), ], ), const SizedBox(height: 12), shimmerFill(0.5), const SizedBox(height: 10), Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: i == 1 ? 8.0 : 0), child: Column( crossAxisAlignment: i == 0 ? CrossAxisAlignment.start : i == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.end, children: [ shimmerBox(70, 11), const SizedBox(height: 4), shimmerBox(50, 13) ], ), ), ))), const SizedBox(height: 10), Row( children: List.generate( 3, (i) => Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: i == 1 ? 8.0 : 0), child: Column( crossAxisAlignment: i == 0 ? CrossAxisAlignment.start : i == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.end, children: [ shimmerBox(70, 11), const SizedBox(height: 4), shimmerBox(50, 13) ], ), ), ))), ], ), ), ); } } class _Badge extends StatelessWidget { const _Badge({required this.text, required this.color}); final String text; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color.withAlpha(30), borderRadius: BorderRadius.circular(4), ), child: Text(text, style: TextStyle( color: color, fontSize: 11, fontWeight: FontWeight.w600)), ); } } class _OrderStat extends StatelessWidget { const _OrderStat( {required this.label, required this.value, this.alignEnd = false, this.alignCenter = false}); final String label; final String value; final bool alignEnd; final bool alignCenter; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final align = alignEnd ? CrossAxisAlignment.end : alignCenter ? CrossAxisAlignment.center : CrossAxisAlignment.start; return Expanded( child: Column( crossAxisAlignment: align, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(value, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600)), ], ), ); } } // ── 分享带单 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 (context.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 (context.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 (context.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), ), ], ), ], ), ), ); } }