import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/dialog_utils.dart'; import '../../../core/utils/top_toast.dart'; import '../../../data/models/copy_trading/trader.dart'; import '../../../data/repositories/copy_trading_repository.dart'; import '../../../providers/auth_provider.dart'; import '../../../providers/copy_trading_provider.dart'; import '../../widgets/common/app_refresh_indicator.dart'; import '../../widgets/common/app_shimmer.dart'; import '../../widgets/common/app_tab_bar.dart'; class CopyTradingScreen extends ConsumerStatefulWidget { const CopyTradingScreen({super.key}); @override ConsumerState createState() => _CopyTradingScreenState(); } class _CopyTradingScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; late PageController _pageController; final _searchCtrl = TextEditingController(); /// 上次观察到的路由位置,用于判断是否从子页面返回 String? _prevLocation; @override void didChangeDependencies() { super.didChangeDependencies(); final location = GoRouterState.of(context).uri.toString(); final wasAway = _prevLocation != null && _prevLocation != '/copy-trading'; _prevLocation = location; // 从其他页面(如 /my-trades、/trader-apply 等)返回时刷新权益 if (location == '/copy-trading' && wasAway) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) ref.read(copyTradingProvider.notifier).refresh(); }); } } @override void initState() { super.initState(); // 恢复 provider 中已有的搜索关键词(State 重建时保持搜索文字) final existingKeyword = ref.read(copyTradingProvider).searchKeyword; if (existingKeyword.isNotEmpty) { _searchCtrl.text = existingKeyword; } _tabController = TabController(length: 3, vsync: this); _pageController = PageController(); _tabController.addListener(() { if (!mounted) return; if (_tabController.indexIsChanging) { // 在动画开始前立即清空数据,确保动画过程中就显示骨架 ref.read(copyTradingProvider.notifier).setTab(_tabController.index); _pageController.animateToPage( _tabController.index, duration: const Duration(milliseconds: 280), curve: Curves.easeOut, ); } }); _pageController.addListener(() { if (!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); } }); } @override void dispose() { _tabController.dispose(); _pageController.dispose(); _searchCtrl.dispose(); super.dispose(); } @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 pageBg = isDark ? AppColors.darkBg : AppColors.lightBgSecondary; final state = ref.watch(copyTradingProvider); final isLoggedIn = ref.watch(isLoggedInProvider); return Scaffold( backgroundColor: pageBg, appBar: AppBar( title: Text(AppLocalizations.of(context)!.copyTradingTitle, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), ), // 已登录且权益数据尚在加载中时显示骨架屏 body: isLoggedIn && (state.isLoading && state.wallet == null && state.error == null) ? _CopyTradingFullSkeleton(pageBg: pageBg, cardBg: cardBg) : isLoggedIn && (state.error != null && state.wallet == null) ? 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.read(copyTradingProvider.notifier).refresh(), style: ElevatedButton.styleFrom(backgroundColor: AppColors.brand, foregroundColor: Colors.black), child: Text(AppLocalizations.of(context)!.retry), ), ], ), ) : Listener( // 任意触摸事件都收起键盘(优先于子节点手势消费) onPointerDown: (_) => FocusScope.of(context).unfocus(), child: Column( children: [ // 顶部白色铺满区块:权益卡(或登录提示)+ 申请专家 Banner Container( color: cardBg, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isLoggedIn) _LoginBanner(onLogin: () => context.push('/login'), embedded: true) else _EquityCard( wallet: state.wallet, isTrader: state.isTrader, traderInfo: state.traderInfo, onMyTrades: () => state.isTrader ? context.push('/my-trades') : context.push('/my-copy-trading'), embedded: true, onTransfer: () async { await context.push('/asset/transfer?from=SPOT&to=FOLLOW'); if (context.mounted) { ref.read(copyTradingProvider.notifier).refresh(); } }, ), if (!state.isTrader) _ExpertBanner( onApply: () { if (!isLoggedIn) { context.push('/login'); return; } context.push('/trader-apply'); }, embedded: true, ), const SizedBox(height: 8), ], ), ), // 灰色分隔 Container(height: 8, color: pageBg), // Tab 切换 + 搜索排序(白底) Container( color: cardBg, child: Column( children: [ _TypeTab(controller: _tabController), _SearchRow( controller: _searchCtrl, sort: state.sort, favoriteMode: isLoggedIn && state.tabIndex == 2, onChanged: ref.read(copyTradingProvider.notifier).setSearch, onSortTap: () => _showSortSheet(context), ), const SizedBox(height: 8), ], ), ), // 灰色分隔条 Container(height: 8, color: pageBg), // 交易员列表(PageView 支持左右滑动切换 tab) Expanded( child: NotificationListener( onNotification: (n) { if (n.metrics.axis == Axis.vertical && n is ScrollUpdateNotification && n.metrics.pixels >= n.metrics.maxScrollExtent - 200) { ref.read(copyTradingProvider.notifier).loadMore(); } return false; }, child: PageView.builder( controller: _pageController, physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), itemCount: 3, onPageChanged: (index) { if (_tabController.indexIsChanging) return; _tabController.index = index; // 滑动切换时也立即清空并加载新 tab 数据 ref.read(copyTradingProvider.notifier).setTab(index); }, itemBuilder: (_, __) => (state.isLoading && state.displayTraders.isEmpty) ? ListView.builder( padding: const EdgeInsets.only(top: 4, bottom: 16), itemCount: 4, itemBuilder: (_, __) => const _TraderCardSkeleton(), ) : AppRefreshIndicator( onRefresh: () => ref.read(copyTradingProvider.notifier).refresh(), child: state.displayTraders.isEmpty && !state.isLoading ? ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox( height: 200, child: Center( child: Text( isLoggedIn && state.tabIndex == 2 ? AppLocalizations.of(context)!.noFavoriteTraders : AppLocalizations.of(context)!.noTraders, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 14), ), ), ), ], ) : ListView.builder( physics: const AlwaysScrollableScrollPhysics(), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only(bottom: 16), itemCount: state.displayTraders.length + 1, itemBuilder: (_, i) { if (i >= state.displayTraders.length) { if (state.isLoadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center(child: CircularProgressIndicator(color: AppColors.brand, strokeWidth: 2)), ); } if (!state.hasMore && state.traders.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: 16); } return _TraderCard( trader: state.displayTraders[i], showFollowButton: !state.isTrader, ); }, ), ), ), ), ), ], ), ), ); } void _showSortSheet(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetCtx) => _SortSheet( current: ref.read(copyTradingProvider).sort, onSelect: (s) { ref.read(copyTradingProvider.notifier).setSort(s); Navigator.pop(sheetCtx); }, ), ); } } // ── 权益卡 ─────────────────────────────────────────────── class _EquityCard extends StatefulWidget { const _EquityCard({ required this.onMyTrades, required this.isTrader, this.wallet, this.traderInfo, this.embedded = false, this.onTransfer, }); final VoidCallback onMyTrades; final bool isTrader; final Map? wallet; final Map? traderInfo; final bool embedded; final VoidCallback? onTransfer; @override State<_EquityCard> createState() => _EquityCardState(); } class _EquityCardState extends State<_EquityCard> { bool _visible = true; /// 格式化数字,向下截断(不四舍五入,对应 Android RoundingMode.DOWN) String _fmt(dynamic raw, {int decimals = 2}) { if (raw == null) return '--'; final str = raw.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; } String get _balance { final w = widget.wallet; if (w == null) return '--'; // 带单员和跟单员均取 currentCapital(账户总权益) final v = w['currentCapital'] ?? w['balance'] ?? '0'; return _fmt(v); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final isDark = Theme.of(context).brightness == Brightness.dark; final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( widget.isTrader ? l10n.contractAccountEquity : l10n.copyAccountEquity, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), const SizedBox(width: 6), GestureDetector( onTap: () => setState(() => _visible = !_visible), child: Padding( padding: const EdgeInsets.all(6), child: Icon( _visible ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 16, color: cs.onSurface.withAlpha(153), ), ), ), GestureDetector( onTap: widget.onTransfer != null ? widget.onTransfer : () => context.push('/asset/transfer?from=SPOT&to=FOLLOW'), child: Padding( padding: const EdgeInsets.all(6), child: Icon(Icons.sync_alt, size: 16, color: cs.onSurface.withAlpha(153)), ), ), ], ), const SizedBox(height: 6), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( _visible ? _balance : '* * * *', style: TextStyle(color: cs.onSurface, fontSize: 22, fontWeight: FontWeight.w700), ), const SizedBox(width: 6), Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 13)), ], ), ], ), ), ElevatedButton( onPressed: widget.onMyTrades, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, elevation: 0, ), child: Text( widget.isTrader ? l10n.myTrading : l10n.myFollowing, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), ), ], ), // 带单员额外统计行 if (widget.isTrader) ...[ const SizedBox(height: 14), Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: cs.onSurface.withAlpha(10), borderRadius: BorderRadius.circular(10), ), child: IntrinsicHeight( child: Row( children: [ _TraderStat( label: l10n.thisSettlementIncome, value: _visible ? () { final v = _fmt(widget.traderInfo?['currentClearedProfit'] ?? widget.traderInfo?['curCycleProfit']); return v == '--' ? '0.00' : v; }() : '* * *', ), VerticalDivider(width: 1, thickness: 0.5, indent: 4, endIndent: 4, color: cs.outlineVariant.withAlpha(80)), _TraderStat( label: l10n.cumulativeProfitShare, value: _visible ? _fmt(widget.traderInfo?['totalFollowProfit']) : '* * *', valueColor: AppColors.rise, alignCenter: true, ), VerticalDivider(width: 1, thickness: 0.5, indent: 4, endIndent: 4, color: cs.outlineVariant.withAlpha(80)), _TraderStat( label: l10n.currentFollowers, value: _visible ? '${widget.traderInfo?['following'] ?? '--'}/${widget.traderInfo?['maxFollow'] ?? '--'}' : '* * *', alignEnd: true, ), ], ), ), ), ], ], ); if (widget.embedded) { return Padding(padding: const EdgeInsets.all(16), child: content); } return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, borderRadius: BorderRadius.circular(12), ), child: content, ); } } class _TraderStat extends StatelessWidget { const _TraderStat({required this.label, required this.value, this.valueColor, this.alignCenter = false, this.alignEnd = false}); final String label; final String value; final Color? valueColor; final bool alignCenter; final bool alignEnd; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final align = alignEnd ? CrossAxisAlignment.end : alignCenter ? CrossAxisAlignment.center : CrossAxisAlignment.start; return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( crossAxisAlignment: align, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)), const SizedBox(height: 5), Text( value, style: TextStyle( color: valueColor ?? cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } // ── 申请专家 Banner ────────────────────────────────────── class _ExpertBanner extends StatelessWidget { const _ExpertBanner({required this.onApply, this.embedded = false}); final VoidCallback onApply; final bool embedded; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final inner = Container( margin: embedded ? const EdgeInsets.fromLTRB(12, 0, 12, 12) : const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppColors.brand.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), border: Border.all(color: AppColors.brand.withValues(alpha: 0.2)), ), child: Row( children: [ Expanded( child: Text( AppLocalizations.of(context)!.applyExpertBannerText, style: TextStyle(color: cs.onSurface.withAlpha(200), fontSize: 13), ), ), GestureDetector( onTap: onApply, child: Row( children: [ Text(AppLocalizations.of(context)!.applyNow, style: const TextStyle(color: AppColors.brand, fontSize: 13, fontWeight: FontWeight.w600)), const SizedBox(width: 2), const Icon(Icons.arrow_forward, size: 14, color: AppColors.brand), ], ), ), ], ), ); if (embedded) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), inner, ], ); } return inner; } } // ── 类型 Tab ───────────────────────────────────────────── class _TypeTab extends ConsumerWidget { const _TypeTab({required this.controller}); final TabController controller; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final isLoggedIn = ref.watch(isLoggedInProvider); return Container( decoration: BoxDecoration( border: Border( bottom: BorderSide(color: cs.outlineVariant.withAlpha(60), width: 1), ), ), child: TabBar( controller: controller, indicator: StretchTabIndicator( controller: controller, color: AppColors.brand, ), indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, labelColor: AppColors.brand, unselectedLabelColor: cs.onSurface.withAlpha(153), labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), unselectedLabelStyle: const TextStyle(fontSize: 14), tabs: [ Tab(text: l10n.regularCopy), Tab(text: l10n.losslessCopy), Tab(text: isLoggedIn ? l10n.myFavoriteTraders : l10n.all), ], ), ); } } // ── 搜索 + 排序行 ───────────────────────────────────────── class _SearchRow extends StatefulWidget { const _SearchRow({ required this.controller, required this.sort, this.favoriteMode = false, required this.onChanged, required this.onSortTap, }); final TextEditingController controller; final TraderSort sort; /// 与 Web 收藏 tab 一致:不展示排序,仅展示说明文案 final bool favoriteMode; final ValueChanged onChanged; final VoidCallback onSortTap; @override State<_SearchRow> createState() => _SearchRowState(); } class _SearchRowState extends State<_SearchRow> { final _focusNode = FocusNode(); @override void dispose() { _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final sortLabel = switch (widget.sort) { TraderSort.winRate30d => l10n.twoWeekWinRate, TraderSort.roi30d => l10n.twoWeekRoi, TraderSort.comprehensive => l10n.comprehensiveSort, }; final noBorder = OutlineInputBorder( borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none, ); return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( children: [ if (widget.favoriteMode) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 140), child: Text( l10n.favoriteTradersFilterHint, style: TextStyle(color: cs.onSurface.withAlpha(200), fontSize: 13), maxLines: 2, overflow: TextOverflow.ellipsis, ), ) else GestureDetector( onTap: widget.onSortTap, child: Row( children: [ Text(sortLabel, style: TextStyle(color: cs.onSurface, fontSize: 13)), const SizedBox(width: 4), Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 18), ], ), ), const SizedBox(width: 12), Expanded( child: SizedBox( height: 34, child: TextField( controller: widget.controller, focusNode: _focusNode, onChanged: widget.onChanged, onSubmitted: (_) => _focusNode.unfocus(), style: const TextStyle(fontSize: 13), decoration: InputDecoration( hintText: l10n.searchNickname, hintStyle: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13), prefixIcon: Icon(Icons.search, size: 16, color: cs.onSurface.withAlpha(153)), contentPadding: EdgeInsets.zero, isDense: true, filled: true, fillColor: cs.onSurface.withAlpha(20), enabledBorder: noBorder, focusedBorder: noBorder, border: noBorder, ), ), ), ), ], ), ); } } // ── 交易员卡片 ─────────────────────────────────────────── class _TraderCard extends ConsumerWidget { const _TraderCard({required this.trader, this.showFollowButton = true}); final Trader trader; final bool showFollowButton; static const _avatarColors = [ Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff), Color(0xFFf3ba2f), Color(0xFF2775ca), Color(0xFF00aae4), ]; Color get _avatarBg => _avatarColors[trader.avatarLetter.codeUnitAt(0) % _avatarColors.length]; /// 跟随人数是否已满(且当前用户未跟随) bool get _isFull => !trader.isFollowing && trader.maxFollowers != null && trader.followers >= trader.maxFollowers!; void _goDetail(BuildContext context, WidgetRef ref) { if (trader.id.isEmpty) return; if (!ref.read(isLoggedInProvider)) { context.push('/login'); return; } context.push('/trader-detail/${trader.id}'); } Future _handleUnfollow(BuildContext context, WidgetRef ref) async { if (!ref.read(isLoggedInProvider)) { context.push('/login'); return; } final confirmed = await showConfirmDialog( context, content: AppLocalizations.of(context)!.unfollowConfirmMsg, ); if (!confirmed || !context.mounted) return; try { await ref.read(copyTradingRepositoryProvider).unfollowTrader(trader.id); ref.read(copyTradingProvider.notifier).refresh(); } catch (e) { if (context.mounted) showTipDialog(context, content: extractErrorMessage(e)); } } @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final isDark = Theme.of(context).brightness == Brightness.dark; final isLoggedIn = ref.watch(isLoggedInProvider); final isTrader = ref.watch(copyTradingProvider.select((s) => s.isTrader)); return GestureDetector( onTap: () => _goDetail(context, ref), child: Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头部:头像 + 名称 + 跟随人数 + 跟单按钮 Row( children: [ // 头像 + 等级角标 SizedBox( width: 44, height: 52, // extra space for level badge child: Stack( clipBehavior: Clip.none, children: [ if (trader.avatarUrl != null && trader.avatarUrl!.isNotEmpty) ClipOval( child: Image.network( trader.avatarUrl!, width: 44, height: 44, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: 44, height: 44, decoration: BoxDecoration(color: _avatarBg, shape: BoxShape.circle), child: Center( child: Text(trader.avatarLetter, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)), ), ), ), ) else Container( width: 44, height: 44, decoration: BoxDecoration(color: _avatarBg, shape: BoxShape.circle), child: Center( child: Text( trader.avatarLetter, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700), ), ), ), // 等级角标 if (trader.levelName != null && trader.levelName!.isNotEmpty) Positioned( bottom: 0, left: 0, right: 0, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: AppColors.darkBadgeBg, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.darkBgMid), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.layers, size: 8, color: AppColors.rankPurple), const SizedBox(width: 2), Flexible( child: Text( trader.levelName!, style: const TextStyle( color: AppColors.rankPurple, fontSize: 9, fontWeight: FontWeight.w700), overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ), ], ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trader.name, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)), Row( children: [ Icon(Icons.person_outline, size: 13, color: cs.onSurface.withAlpha(130)), const SizedBox(width: 3), Text( trader.maxFollowers != null ? '${trader.followers}/${trader.maxFollowers}' : '${trader.followers}', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), // 满员标签 if (_isFull) ...[ const SizedBox(width: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: AppColors.fall.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(3), ), child: Text(l10n.full, style: const TextStyle(color: AppColors.fall, fontSize: 10, fontWeight: FontWeight.w600)), ), ], ], ), ], ), ), if (isLoggedIn && !isTrader) ...[ IconButton( padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), icon: Icon( trader.isFavorited ? Icons.star_rounded : Icons.star_outline_rounded, size: 24, color: trader.isFavorited ? AppColors.brand : cs.onSurface.withAlpha(153), ), onPressed: () async { final next = await ref.read(copyTradingProvider.notifier).toggleFavorite(trader); if (!context.mounted) { return; } if (next == true) { showTopToast( context, message: l10n.addedToFavorites, backgroundColor: const Color(0xFF2ECC71), ); } else if (next == null) { showTipDialog(context, content: l10n.operationFailedRetry); } }, ), ], if (showFollowButton) trader.isFollowing ? OutlinedButton( onPressed: () => _handleUnfollow(context, ref), style: OutlinedButton.styleFrom( side: BorderSide(color: cs.onSurface, width: 1.5), foregroundColor: cs.onSurface, backgroundColor: cs.surface, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 0), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), minimumSize: const Size(0, 32), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text(l10n.unfollow, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ) : _isFull ? OutlinedButton( onPressed: null, style: OutlinedButton.styleFrom( side: BorderSide(color: cs.outline.withAlpha(60)), foregroundColor: cs.onSurface.withAlpha(100), backgroundColor: Colors.transparent, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), minimumSize: const Size(0, 32), tapTargetSize: MaterialTapTargetSize.shrinkWrap, disabledForegroundColor: cs.onSurface.withAlpha(80), ), child: Text(l10n.full, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), ) : ElevatedButton( onPressed: () => _goDetail(context, ref), style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), shape: const StadiumBorder(), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, elevation: 0, ), child: Text(l10n.copyTrading, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), ), ], ), // 数据行 1:收益率 / 收益 / 分润比例 Row( children: [ _StatItem( label: l10n.twoWeekRoi, value: trader.roi30d == 0 ? '--' : '${trader.roi30d >= 0 ? '+' : ''}${trader.roi30d.toStringAsFixed(2)}%', valueColor: trader.roi30d >= 0 ? AppColors.rise : AppColors.fall, ), _StatItem( label: l10n.profitUsdtLabel, value: trader.profit30d == 0 ? '--' : '${trader.profit30d >= 0 ? '+' : ''}${trader.profit30d.toStringAsFixed(2)}', valueColor: trader.profit30d >= 0 ? AppColors.rise : AppColors.fall, ), _StatItem( label: l10n.profitShare, value: trader.profitShare == 0 ? '--' : '${trader.profitShare.toStringAsFixed(0)}%', ), ], ), const SizedBox(height: 10), // 数据行 2:近14天走势(标签)+ 迷你图 Divider(height: 1, thickness: 0.5, color: cs.onSurface.withAlpha(35)), const SizedBox(height: 10), Row( children: [ Text(l10n.twoWeekTrend, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)), const Spacer(), SizedBox(width: 120, child: _MiniChart(data: trader.trendData, isPositive: trader.roi30d >= 0)), ], ), const SizedBox(height: 10), Divider(height: 1, thickness: 0.5, color: cs.onSurface.withAlpha(35)), const SizedBox(height: 10), // 数据行 3:胜率 Row( children: [ _StatItem(label: l10n.twoWeekWinRate, value: trader.winRate == 0 ? '--' : '${trader.winRate.toStringAsFixed(1)}%'), const Expanded(child: SizedBox()), ], ), ], ), ), ); } } class _StatItem extends StatelessWidget { const _StatItem({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)), ], ), ); } } // ── 迷你折线图 ──────────────────────────────────────────── class _MiniChart extends StatelessWidget { const _MiniChart({required this.data, required this.isPositive}); final List data; final bool isPositive; @override Widget build(BuildContext context) { if (data.isEmpty) return const SizedBox.shrink(); return SizedBox( height: 36, child: CustomPaint( painter: _MiniChartPainter(data: data, isPositive: isPositive), ), ); } } class _MiniChartPainter extends CustomPainter { const _MiniChartPainter({required this.data, required this.isPositive}); final List data; final bool isPositive; @override void paint(Canvas canvas, Size size) { if (data.length < 2) return; final min = data.reduce((a, b) => a < b ? a : b); final max = data.reduce((a, b) => a > b ? a : b); final range = (max - min).abs(); // 颜色与近14天收益率标签保持一致(roi30d >= 0 为绿,否则为红) final lineColor = isPositive ? AppColors.rise : AppColors.fall; // Compute all points final points = List.generate(data.length, (i) { final x = i / (data.length - 1) * size.width; final y = range == 0 ? size.height / 2 : (1 - (data[i] - min) / range) * size.height; return Offset(x, y); }); final smoothLine = _smoothPath(points); // Gradient fill:同样用单调插值路径,首尾封底 final fillPath = Path() ..moveTo(points.first.dx, size.height) ..lineTo(points.first.dx, points.first.dy); _appendMonotoneCurve(fillPath, points); fillPath ..lineTo(points.last.dx, size.height) ..close(); canvas.drawPath( fillPath, Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [lineColor.withValues(alpha: 0.35), lineColor.withValues(alpha: 0.0)], ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) ..style = PaintingStyle.fill, ); // Smooth line on top canvas.drawPath( smoothLine, Paint() ..color = lineColor ..strokeWidth = 1.8 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round, ); } /// 单调三次插值(Fritsch-Carlson),保证曲线不超出数据点范围,不会重叠 Path _smoothPath(List pts) { final path = Path()..moveTo(pts.first.dx, pts.first.dy); _appendMonotoneCurve(path, pts); return path; } void _appendMonotoneCurve(Path path, List pts) { final n = pts.length; if (n < 2) return; // 各段斜率 final delta = List.generate(n - 1, (i) { final dx = pts[i + 1].dx - pts[i].dx; return dx == 0 ? 0 : (pts[i + 1].dy - pts[i].dy) / dx; }); // 各点切线斜率(相邻段平均) final m = List.filled(n, 0); m[0] = delta[0]; for (var i = 1; i < n - 1; i++) { m[i] = (delta[i - 1] + delta[i]) / 2; } m[n - 1] = delta[n - 2]; // Fritsch-Carlson 单调性修正:防止斜率过大导致曲线超出范围 for (var i = 0; i < n - 1; i++) { if (delta[i] == 0) { m[i] = 0; m[i + 1] = 0; } else { final alpha = m[i] / delta[i]; final beta = m[i + 1] / delta[i]; final tau = alpha * alpha + beta * beta; if (tau > 9) { final t = 3 / math.sqrt(tau); m[i] = t * alpha * delta[i]; m[i + 1] = t * beta * delta[i]; } } } // 输出三次 Hermite 曲线段 for (var i = 0; i < n - 1; i++) { final dx = pts[i + 1].dx - pts[i].dx; final cp1 = Offset(pts[i].dx + dx / 3, pts[i].dy + m[i] * dx / 3); final cp2 = Offset(pts[i + 1].dx - dx / 3, pts[i + 1].dy - m[i + 1] * dx / 3); path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, pts[i + 1].dx, pts[i + 1].dy); } } @override bool shouldRepaint(_MiniChartPainter old) => old.data != data || old.isPositive != isPositive; } // ── 未登录提示卡 ───────────────────────────────────────── class _LoginBanner extends StatelessWidget { const _LoginBanner({required this.onLogin, this.embedded = false}); final VoidCallback onLogin; final bool embedded; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final card = Container( margin: embedded ? const EdgeInsets.fromLTRB(16, 0, 16, 0) : const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(16), border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of(context)!.loginToViewAccount, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600), ), const SizedBox(height: 4), Text( AppLocalizations.of(context)!.loginToFollowExpert, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), ], ), ), const SizedBox(width: 12), ElevatedButton( onPressed: onLogin, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), shape: const StadiumBorder(), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, elevation: 0, ), child: Text(AppLocalizations.of(context)!.loginNow, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), ), ], ), ); if (embedded) { return Padding( padding: const EdgeInsets.fromLTRB(0, 16, 0, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 0, 8), child: Text( AppLocalizations.of(context)!.copyAccountEquity, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), ), card, ], ), ); } return card; } } // ── 骨架屏 ──────────────────────────────────────────────── /// 首次进入时的全页骨架屏(权益卡 + Tab栏 + 列表) class _CopyTradingFullSkeleton extends StatelessWidget { const _CopyTradingFullSkeleton({required this.pageBg, required this.cardBg}); final Color pageBg; final Color cardBg; @override Widget build(BuildContext context) { return Column( children: [ // 权益卡骨架 Container( color: cardBg, padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), child: AppShimmer( child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(110, 12), const SizedBox(height: 10), shimmerBox(180, 26), ], ), ), shimmerBox(80, 36, radius: 8), ], ), ), ), Container(height: 8, color: pageBg), // Tab + 搜索骨架 Container( color: cardBg, padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: AppShimmer( child: Column( children: [ shimmerFill(40, radius: 10), const SizedBox(height: 10), shimmerFill(34, radius: 20), ], ), ), ), Container(height: 8, color: pageBg), // 列表骨架 Expanded( child: ListView.builder( padding: const EdgeInsets.only(top: 4, bottom: 16), itemCount: 4, itemBuilder: (_, __) => const _TraderCardSkeleton(), ), ), ], ); } } /// 交易员卡片骨架(与 _TraderCard 布局对应) class _TraderCardSkeleton extends StatelessWidget { const _TraderCardSkeleton(); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return AppShimmer( child: Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 头部:头像 + 名称 + 按钮 Row( children: [ shimmerCircle(44), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(120, 14), const SizedBox(height: 6), shimmerBox(80, 11), ], ), ), shimmerBox(72, 32, radius: 20), ], ), const SizedBox(height: 14), // 数据行 1:3 列统计 Row( children: List.generate(3, (i) => Expanded( child: Padding( padding: EdgeInsets.only(right: i < 2 ? 8 : 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(55, 11), const SizedBox(height: 4), shimmerBox(45, 13), ], ), ), )), ), const SizedBox(height: 10), shimmerFill(0.5), const SizedBox(height: 10), // 走势行 Row( children: [ shimmerBox(70, 11), const Spacer(), shimmerBox(120, 36), ], ), const SizedBox(height: 10), shimmerFill(0.5), const SizedBox(height: 10), // 数据行 2:2 列 Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 11), const SizedBox(height: 4), shimmerBox(45, 13), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 11), const SizedBox(height: 4), shimmerBox(45, 13), ], ), ), const Expanded(child: SizedBox()), ], ), ], ), ), ); } } // ── 排序底部弹层 ────────────────────────────────────────── class _SortSheet extends StatelessWidget { const _SortSheet({required this.current, required this.onSelect}); final TraderSort current; final ValueChanged onSelect; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final options = [ (TraderSort.comprehensive, l10n.comprehensiveSort), (TraderSort.winRate30d, l10n.twoWeekWinRate), (TraderSort.roi30d, l10n.twoWeekRoi), ]; final cs = Theme.of(context).colorScheme; return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), Container(width: 36, height: 4, decoration: BoxDecoration(color: cs.outline.withAlpha(30), borderRadius: BorderRadius.circular(2))), const SizedBox(height: 12), ...options.map( (o) => GestureDetector( onTap: () => onSelect(o.$1), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ Expanded(child: Text(o.$2, style: TextStyle(color: current == o.$1 ? AppColors.brand : cs.onSurface, fontSize: 15))), if (current == o.$1) const Icon(Icons.check, color: AppColors.brand, size: 20), ], ), ), ), ), const SizedBox(height: 8), ], ), ); } }