| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463 |
- 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<CopyTradingScreen> createState() => _CopyTradingScreenState();
- }
- class _CopyTradingScreenState extends ConsumerState<CopyTradingScreen>
- 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<ScrollNotification>(
- 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<String, dynamic>? wallet;
- final Map<String, dynamic>? 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<String> 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<void> _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<double> 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<double> 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<Offset> pts) {
- final path = Path()..moveTo(pts.first.dx, pts.first.dy);
- _appendMonotoneCurve(path, pts);
- return path;
- }
- void _appendMonotoneCurve(Path path, List<Offset> pts) {
- final n = pts.length;
- if (n < 2) return;
- // 各段斜率
- final delta = List<double>.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<double>.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<TraderSort> 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),
- ],
- ),
- );
- }
- }
|