copy_trading_screen.dart 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/dialog_utils.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../data/models/copy_trading/trader.dart';
  10. import '../../../data/repositories/copy_trading_repository.dart';
  11. import '../../../providers/auth_provider.dart';
  12. import '../../../providers/copy_trading_provider.dart';
  13. import '../../widgets/common/app_refresh_indicator.dart';
  14. import '../../widgets/common/app_shimmer.dart';
  15. import '../../widgets/common/app_tab_bar.dart';
  16. class CopyTradingScreen extends ConsumerStatefulWidget {
  17. const CopyTradingScreen({super.key});
  18. @override
  19. ConsumerState<CopyTradingScreen> createState() => _CopyTradingScreenState();
  20. }
  21. class _CopyTradingScreenState extends ConsumerState<CopyTradingScreen>
  22. with SingleTickerProviderStateMixin {
  23. late TabController _tabController;
  24. late PageController _pageController;
  25. final _searchCtrl = TextEditingController();
  26. /// 上次观察到的路由位置,用于判断是否从子页面返回
  27. String? _prevLocation;
  28. @override
  29. void didChangeDependencies() {
  30. super.didChangeDependencies();
  31. final location = GoRouterState.of(context).uri.toString();
  32. final wasAway = _prevLocation != null && _prevLocation != '/copy-trading';
  33. _prevLocation = location;
  34. // 从其他页面(如 /my-trades、/trader-apply 等)返回时刷新权益
  35. if (location == '/copy-trading' && wasAway) {
  36. WidgetsBinding.instance.addPostFrameCallback((_) {
  37. if (mounted) ref.read(copyTradingProvider.notifier).refresh();
  38. });
  39. }
  40. }
  41. @override
  42. void initState() {
  43. super.initState();
  44. // 恢复 provider 中已有的搜索关键词(State 重建时保持搜索文字)
  45. final existingKeyword = ref.read(copyTradingProvider).searchKeyword;
  46. if (existingKeyword.isNotEmpty) {
  47. _searchCtrl.text = existingKeyword;
  48. }
  49. _tabController = TabController(length: 3, vsync: this);
  50. _pageController = PageController();
  51. _tabController.addListener(() {
  52. if (!mounted) return;
  53. if (_tabController.indexIsChanging) {
  54. // 在动画开始前立即清空数据,确保动画过程中就显示骨架
  55. ref.read(copyTradingProvider.notifier).setTab(_tabController.index);
  56. _pageController.animateToPage(
  57. _tabController.index,
  58. duration: const Duration(milliseconds: 280),
  59. curve: Curves.easeOut,
  60. );
  61. }
  62. });
  63. _pageController.addListener(() {
  64. if (!mounted) return;
  65. if (!_pageController.hasClients) return;
  66. final page = _pageController.page!;
  67. final offset = page - _tabController.index;
  68. if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
  69. _tabController.offset = offset.clamp(-1.0, 1.0);
  70. }
  71. });
  72. }
  73. @override
  74. void dispose() {
  75. _tabController.dispose();
  76. _pageController.dispose();
  77. _searchCtrl.dispose();
  78. super.dispose();
  79. }
  80. @override
  81. Widget build(BuildContext context) {
  82. final cs = Theme.of(context).colorScheme;
  83. final isDark = Theme.of(context).brightness == Brightness.dark;
  84. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  85. final pageBg = isDark ? AppColors.darkBg : AppColors.lightBgSecondary;
  86. final state = ref.watch(copyTradingProvider);
  87. final isLoggedIn = ref.watch(isLoggedInProvider);
  88. return Scaffold(
  89. backgroundColor: pageBg,
  90. appBar: AppBar(
  91. title: Text(AppLocalizations.of(context)!.copyTradingTitle, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
  92. ),
  93. // 已登录且权益数据尚在加载中时显示骨架屏
  94. body: isLoggedIn && (state.isLoading && state.wallet == null && state.error == null)
  95. ? _CopyTradingFullSkeleton(pageBg: pageBg, cardBg: cardBg)
  96. : isLoggedIn && (state.error != null && state.wallet == null)
  97. ? Center(
  98. child: Column(
  99. mainAxisSize: MainAxisSize.min,
  100. children: [
  101. Text(AppLocalizations.of(context)!.loadFailed, style: TextStyle(color: cs.onSurface.withAlpha(153))),
  102. const SizedBox(height: 12),
  103. ElevatedButton(
  104. onPressed: () => ref.read(copyTradingProvider.notifier).refresh(),
  105. style: ElevatedButton.styleFrom(backgroundColor: AppColors.brand, foregroundColor: Colors.black),
  106. child: Text(AppLocalizations.of(context)!.retry),
  107. ),
  108. ],
  109. ),
  110. )
  111. : Listener(
  112. // 任意触摸事件都收起键盘(优先于子节点手势消费)
  113. onPointerDown: (_) => FocusScope.of(context).unfocus(),
  114. child: Column(
  115. children: [
  116. // 顶部白色铺满区块:权益卡(或登录提示)+ 申请专家 Banner
  117. Container(
  118. color: cardBg,
  119. child: Column(
  120. crossAxisAlignment: CrossAxisAlignment.start,
  121. children: [
  122. if (!isLoggedIn)
  123. _LoginBanner(onLogin: () => context.push('/login'), embedded: true)
  124. else
  125. _EquityCard(
  126. wallet: state.wallet,
  127. isTrader: state.isTrader,
  128. traderInfo: state.traderInfo,
  129. onMyTrades: () => state.isTrader
  130. ? context.push('/my-trades')
  131. : context.push('/my-copy-trading'),
  132. embedded: true,
  133. onTransfer: () async {
  134. await context.push('/asset/transfer?from=SPOT&to=FOLLOW');
  135. if (context.mounted) {
  136. ref.read(copyTradingProvider.notifier).refresh();
  137. }
  138. },
  139. ),
  140. if (!state.isTrader)
  141. _ExpertBanner(
  142. onApply: () {
  143. if (!isLoggedIn) { context.push('/login'); return; }
  144. context.push('/trader-apply');
  145. },
  146. embedded: true,
  147. ),
  148. const SizedBox(height: 8),
  149. ],
  150. ),
  151. ),
  152. // 灰色分隔
  153. Container(height: 8, color: pageBg),
  154. // Tab 切换 + 搜索排序(白底)
  155. Container(
  156. color: cardBg,
  157. child: Column(
  158. children: [
  159. _TypeTab(controller: _tabController),
  160. _SearchRow(
  161. controller: _searchCtrl,
  162. sort: state.sort,
  163. favoriteMode: isLoggedIn && state.tabIndex == 2,
  164. onChanged: ref.read(copyTradingProvider.notifier).setSearch,
  165. onSortTap: () => _showSortSheet(context),
  166. ),
  167. const SizedBox(height: 8),
  168. ],
  169. ),
  170. ),
  171. // 灰色分隔条
  172. Container(height: 8, color: pageBg),
  173. // 交易员列表(PageView 支持左右滑动切换 tab)
  174. Expanded(
  175. child: NotificationListener<ScrollNotification>(
  176. onNotification: (n) {
  177. if (n.metrics.axis == Axis.vertical &&
  178. n is ScrollUpdateNotification &&
  179. n.metrics.pixels >= n.metrics.maxScrollExtent - 200) {
  180. ref.read(copyTradingProvider.notifier).loadMore();
  181. }
  182. return false;
  183. },
  184. child: PageView.builder(
  185. controller: _pageController,
  186. physics: const BouncingScrollPhysics(
  187. parent: AlwaysScrollableScrollPhysics(),
  188. ),
  189. itemCount: 3,
  190. onPageChanged: (index) {
  191. if (_tabController.indexIsChanging) return;
  192. _tabController.index = index;
  193. // 滑动切换时也立即清空并加载新 tab 数据
  194. ref.read(copyTradingProvider.notifier).setTab(index);
  195. },
  196. itemBuilder: (_, __) =>
  197. (state.isLoading && state.displayTraders.isEmpty)
  198. ? ListView.builder(
  199. padding: const EdgeInsets.only(top: 4, bottom: 16),
  200. itemCount: 4,
  201. itemBuilder: (_, __) => const _TraderCardSkeleton(),
  202. )
  203. : AppRefreshIndicator(
  204. onRefresh: () => ref.read(copyTradingProvider.notifier).refresh(),
  205. child: state.displayTraders.isEmpty && !state.isLoading
  206. ? ListView(
  207. physics: const AlwaysScrollableScrollPhysics(),
  208. children: [
  209. SizedBox(
  210. height: 200,
  211. child: Center(
  212. child: Text(
  213. isLoggedIn && state.tabIndex == 2
  214. ? AppLocalizations.of(context)!.noFavoriteTraders
  215. : AppLocalizations.of(context)!.noTraders,
  216. textAlign: TextAlign.center,
  217. style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 14),
  218. ),
  219. ),
  220. ),
  221. ],
  222. )
  223. : ListView.builder(
  224. physics: const AlwaysScrollableScrollPhysics(),
  225. keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  226. padding: const EdgeInsets.only(bottom: 16),
  227. itemCount: state.displayTraders.length + 1,
  228. itemBuilder: (_, i) {
  229. if (i >= state.displayTraders.length) {
  230. if (state.isLoadingMore) {
  231. return const Padding(
  232. padding: EdgeInsets.symmetric(vertical: 16),
  233. child: Center(child: CircularProgressIndicator(color: AppColors.brand, strokeWidth: 2)),
  234. );
  235. }
  236. if (!state.hasMore && state.traders.isNotEmpty) {
  237. return Padding(
  238. padding: const EdgeInsets.symmetric(vertical: 16),
  239. child: Center(child: Text(AppLocalizations.of(context)!.noMore, style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 12))),
  240. );
  241. }
  242. return const SizedBox(height: 16);
  243. }
  244. return _TraderCard(
  245. trader: state.displayTraders[i],
  246. showFollowButton: !state.isTrader,
  247. );
  248. },
  249. ),
  250. ),
  251. ),
  252. ),
  253. ),
  254. ],
  255. ),
  256. ),
  257. );
  258. }
  259. void _showSortSheet(BuildContext context) {
  260. final isDark = Theme.of(context).brightness == Brightness.dark;
  261. showModalBottomSheet(
  262. context: context,
  263. useRootNavigator: true,
  264. backgroundColor: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  265. shape: const RoundedRectangleBorder(
  266. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  267. ),
  268. builder: (sheetCtx) => _SortSheet(
  269. current: ref.read(copyTradingProvider).sort,
  270. onSelect: (s) {
  271. ref.read(copyTradingProvider.notifier).setSort(s);
  272. Navigator.pop(sheetCtx);
  273. },
  274. ),
  275. );
  276. }
  277. }
  278. // ── 权益卡 ───────────────────────────────────────────────
  279. class _EquityCard extends StatefulWidget {
  280. const _EquityCard({
  281. required this.onMyTrades,
  282. required this.isTrader,
  283. this.wallet,
  284. this.traderInfo,
  285. this.embedded = false,
  286. this.onTransfer,
  287. });
  288. final VoidCallback onMyTrades;
  289. final bool isTrader;
  290. final Map<String, dynamic>? wallet;
  291. final Map<String, dynamic>? traderInfo;
  292. final bool embedded;
  293. final VoidCallback? onTransfer;
  294. @override
  295. State<_EquityCard> createState() => _EquityCardState();
  296. }
  297. class _EquityCardState extends State<_EquityCard> {
  298. bool _visible = true;
  299. /// 格式化数字,向下截断(不四舍五入,对应 Android RoundingMode.DOWN)
  300. String _fmt(dynamic raw, {int decimals = 2}) {
  301. if (raw == null) return '--';
  302. final str = raw.toString().trim();
  303. if (str.isEmpty) return '--';
  304. final d = double.tryParse(str);
  305. if (d == null) return str;
  306. // 基于原始字符串做截断,避免浮点精度问题
  307. final isNeg = str.startsWith('-');
  308. final absStr = isNeg ? str.substring(1) : str;
  309. final dotIdx = absStr.indexOf('.');
  310. String truncated;
  311. if (decimals == 0 || dotIdx < 0) {
  312. truncated = dotIdx < 0 ? absStr : absStr.substring(0, dotIdx);
  313. } else {
  314. final frac = absStr.substring(dotIdx + 1);
  315. truncated = '${absStr.substring(0, dotIdx)}.${frac.length >= decimals ? frac.substring(0, decimals) : frac.padRight(decimals, '0')}';
  316. }
  317. final parts = truncated.split('.');
  318. final intFmt = parts[0].replaceAllMapped(
  319. RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},');
  320. final result = decimals > 0 ? '$intFmt.${parts.length > 1 ? parts[1] : '0' * decimals}' : intFmt;
  321. return isNeg ? '-$result' : result;
  322. }
  323. String get _balance {
  324. final w = widget.wallet;
  325. if (w == null) return '--';
  326. // 带单员和跟单员均取 currentCapital(账户总权益)
  327. final v = w['currentCapital'] ?? w['balance'] ?? '0';
  328. return _fmt(v);
  329. }
  330. @override
  331. Widget build(BuildContext context) {
  332. final cs = Theme.of(context).colorScheme;
  333. final l10n = AppLocalizations.of(context)!;
  334. final isDark = Theme.of(context).brightness == Brightness.dark;
  335. final content = Column(
  336. crossAxisAlignment: CrossAxisAlignment.start,
  337. children: [
  338. Row(
  339. crossAxisAlignment: CrossAxisAlignment.center,
  340. children: [
  341. Expanded(
  342. child: Column(
  343. crossAxisAlignment: CrossAxisAlignment.start,
  344. children: [
  345. Row(
  346. children: [
  347. Text(
  348. widget.isTrader ? l10n.contractAccountEquity : l10n.copyAccountEquity,
  349. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  350. ),
  351. const SizedBox(width: 6),
  352. GestureDetector(
  353. onTap: () => setState(() => _visible = !_visible),
  354. child: Padding(
  355. padding: const EdgeInsets.all(6),
  356. child: Icon(
  357. _visible ? Icons.visibility_outlined : Icons.visibility_off_outlined,
  358. size: 16,
  359. color: cs.onSurface.withAlpha(153),
  360. ),
  361. ),
  362. ),
  363. GestureDetector(
  364. onTap: widget.onTransfer != null
  365. ? widget.onTransfer
  366. : () => context.push('/asset/transfer?from=SPOT&to=FOLLOW'),
  367. child: Padding(
  368. padding: const EdgeInsets.all(6),
  369. child: Icon(Icons.sync_alt, size: 16, color: cs.onSurface.withAlpha(153)),
  370. ),
  371. ),
  372. ],
  373. ),
  374. const SizedBox(height: 6),
  375. Row(
  376. crossAxisAlignment: CrossAxisAlignment.center,
  377. children: [
  378. Text(
  379. _visible ? _balance : '* * * *',
  380. style: TextStyle(color: cs.onSurface, fontSize: 22, fontWeight: FontWeight.w700),
  381. ),
  382. const SizedBox(width: 6),
  383. Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 13)),
  384. ],
  385. ),
  386. ],
  387. ),
  388. ),
  389. ElevatedButton(
  390. onPressed: widget.onMyTrades,
  391. style: ElevatedButton.styleFrom(
  392. backgroundColor: AppColors.brand,
  393. foregroundColor: Colors.black,
  394. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
  395. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  396. minimumSize: Size.zero,
  397. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  398. elevation: 0,
  399. ),
  400. child: Text(
  401. widget.isTrader ? l10n.myTrading : l10n.myFollowing,
  402. style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
  403. ),
  404. ),
  405. ],
  406. ),
  407. // 带单员额外统计行
  408. if (widget.isTrader) ...[
  409. const SizedBox(height: 14),
  410. Container(
  411. padding: const EdgeInsets.symmetric(vertical: 12),
  412. decoration: BoxDecoration(
  413. color: cs.onSurface.withAlpha(10),
  414. borderRadius: BorderRadius.circular(10),
  415. ),
  416. child: IntrinsicHeight(
  417. child: Row(
  418. children: [
  419. _TraderStat(
  420. label: l10n.thisSettlementIncome,
  421. value: _visible
  422. ? () {
  423. final v = _fmt(widget.traderInfo?['currentClearedProfit'] ?? widget.traderInfo?['curCycleProfit']);
  424. return v == '--' ? '0.00' : v;
  425. }()
  426. : '* * *',
  427. ),
  428. VerticalDivider(width: 1, thickness: 0.5, indent: 4, endIndent: 4, color: cs.outlineVariant.withAlpha(80)),
  429. _TraderStat(
  430. label: l10n.cumulativeProfitShare,
  431. value: _visible
  432. ? _fmt(widget.traderInfo?['totalFollowProfit'])
  433. : '* * *',
  434. valueColor: AppColors.rise,
  435. alignCenter: true,
  436. ),
  437. VerticalDivider(width: 1, thickness: 0.5, indent: 4, endIndent: 4, color: cs.outlineVariant.withAlpha(80)),
  438. _TraderStat(
  439. label: l10n.currentFollowers,
  440. value: _visible
  441. ? '${widget.traderInfo?['following'] ?? '--'}/${widget.traderInfo?['maxFollow'] ?? '--'}'
  442. : '* * *',
  443. alignEnd: true,
  444. ),
  445. ],
  446. ),
  447. ),
  448. ),
  449. ],
  450. ],
  451. );
  452. if (widget.embedded) {
  453. return Padding(padding: const EdgeInsets.all(16), child: content);
  454. }
  455. return Container(
  456. margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
  457. padding: const EdgeInsets.all(16),
  458. decoration: BoxDecoration(
  459. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  460. borderRadius: BorderRadius.circular(12),
  461. ),
  462. child: content,
  463. );
  464. }
  465. }
  466. class _TraderStat extends StatelessWidget {
  467. const _TraderStat({required this.label, required this.value, this.valueColor, this.alignCenter = false, this.alignEnd = false});
  468. final String label;
  469. final String value;
  470. final Color? valueColor;
  471. final bool alignCenter;
  472. final bool alignEnd;
  473. @override
  474. Widget build(BuildContext context) {
  475. final cs = Theme.of(context).colorScheme;
  476. final align = alignEnd
  477. ? CrossAxisAlignment.end
  478. : alignCenter
  479. ? CrossAxisAlignment.center
  480. : CrossAxisAlignment.start;
  481. return Expanded(
  482. child: Padding(
  483. padding: const EdgeInsets.symmetric(horizontal: 12),
  484. child: Column(
  485. crossAxisAlignment: align,
  486. children: [
  487. Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  488. const SizedBox(height: 5),
  489. Text(
  490. value,
  491. style: TextStyle(
  492. color: valueColor ?? cs.onSurface,
  493. fontSize: 14,
  494. fontWeight: FontWeight.w700,
  495. ),
  496. ),
  497. ],
  498. ),
  499. ),
  500. );
  501. }
  502. }
  503. // ── 申请专家 Banner ──────────────────────────────────────
  504. class _ExpertBanner extends StatelessWidget {
  505. const _ExpertBanner({required this.onApply, this.embedded = false});
  506. final VoidCallback onApply;
  507. final bool embedded;
  508. @override
  509. Widget build(BuildContext context) {
  510. final cs = Theme.of(context).colorScheme;
  511. final inner = Container(
  512. margin: embedded
  513. ? const EdgeInsets.fromLTRB(12, 0, 12, 12)
  514. : const EdgeInsets.fromLTRB(16, 0, 16, 8),
  515. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  516. decoration: BoxDecoration(
  517. color: AppColors.brand.withValues(alpha: 0.08),
  518. borderRadius: BorderRadius.circular(10),
  519. border: Border.all(color: AppColors.brand.withValues(alpha: 0.2)),
  520. ),
  521. child: Row(
  522. children: [
  523. Expanded(
  524. child: Text(
  525. AppLocalizations.of(context)!.applyExpertBannerText,
  526. style: TextStyle(color: cs.onSurface.withAlpha(200), fontSize: 13),
  527. ),
  528. ),
  529. GestureDetector(
  530. onTap: onApply,
  531. child: Row(
  532. children: [
  533. Text(AppLocalizations.of(context)!.applyNow, style: const TextStyle(color: AppColors.brand, fontSize: 13, fontWeight: FontWeight.w600)),
  534. const SizedBox(width: 2),
  535. const Icon(Icons.arrow_forward, size: 14, color: AppColors.brand),
  536. ],
  537. ),
  538. ),
  539. ],
  540. ),
  541. );
  542. if (embedded) {
  543. return Column(
  544. crossAxisAlignment: CrossAxisAlignment.start,
  545. children: [
  546. const SizedBox(height: 8),
  547. inner,
  548. ],
  549. );
  550. }
  551. return inner;
  552. }
  553. }
  554. // ── 类型 Tab ─────────────────────────────────────────────
  555. class _TypeTab extends ConsumerWidget {
  556. const _TypeTab({required this.controller});
  557. final TabController controller;
  558. @override
  559. Widget build(BuildContext context, WidgetRef ref) {
  560. final cs = Theme.of(context).colorScheme;
  561. final l10n = AppLocalizations.of(context)!;
  562. final isLoggedIn = ref.watch(isLoggedInProvider);
  563. return Container(
  564. decoration: BoxDecoration(
  565. border: Border(
  566. bottom: BorderSide(color: cs.outlineVariant.withAlpha(60), width: 1),
  567. ),
  568. ),
  569. child: TabBar(
  570. controller: controller,
  571. indicator: StretchTabIndicator(
  572. controller: controller,
  573. color: AppColors.brand,
  574. ),
  575. indicatorSize: TabBarIndicatorSize.tab,
  576. dividerColor: Colors.transparent,
  577. labelColor: AppColors.brand,
  578. unselectedLabelColor: cs.onSurface.withAlpha(153),
  579. labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
  580. unselectedLabelStyle: const TextStyle(fontSize: 14),
  581. tabs: [
  582. Tab(text: l10n.regularCopy),
  583. Tab(text: l10n.losslessCopy),
  584. Tab(text: isLoggedIn ? l10n.myFavoriteTraders : l10n.all),
  585. ],
  586. ),
  587. );
  588. }
  589. }
  590. // ── 搜索 + 排序行 ─────────────────────────────────────────
  591. class _SearchRow extends StatefulWidget {
  592. const _SearchRow({
  593. required this.controller,
  594. required this.sort,
  595. this.favoriteMode = false,
  596. required this.onChanged,
  597. required this.onSortTap,
  598. });
  599. final TextEditingController controller;
  600. final TraderSort sort;
  601. /// 与 Web 收藏 tab 一致:不展示排序,仅展示说明文案
  602. final bool favoriteMode;
  603. final ValueChanged<String> onChanged;
  604. final VoidCallback onSortTap;
  605. @override
  606. State<_SearchRow> createState() => _SearchRowState();
  607. }
  608. class _SearchRowState extends State<_SearchRow> {
  609. final _focusNode = FocusNode();
  610. @override
  611. void dispose() {
  612. _focusNode.dispose();
  613. super.dispose();
  614. }
  615. @override
  616. Widget build(BuildContext context) {
  617. final cs = Theme.of(context).colorScheme;
  618. final l10n = AppLocalizations.of(context)!;
  619. final sortLabel = switch (widget.sort) {
  620. TraderSort.winRate30d => l10n.twoWeekWinRate,
  621. TraderSort.roi30d => l10n.twoWeekRoi,
  622. TraderSort.comprehensive => l10n.comprehensiveSort,
  623. };
  624. final noBorder = OutlineInputBorder(
  625. borderRadius: BorderRadius.circular(20),
  626. borderSide: BorderSide.none,
  627. );
  628. return Padding(
  629. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  630. child: Row(
  631. children: [
  632. if (widget.favoriteMode)
  633. ConstrainedBox(
  634. constraints: const BoxConstraints(maxWidth: 140),
  635. child: Text(
  636. l10n.favoriteTradersFilterHint,
  637. style: TextStyle(color: cs.onSurface.withAlpha(200), fontSize: 13),
  638. maxLines: 2,
  639. overflow: TextOverflow.ellipsis,
  640. ),
  641. )
  642. else
  643. GestureDetector(
  644. onTap: widget.onSortTap,
  645. child: Row(
  646. children: [
  647. Text(sortLabel, style: TextStyle(color: cs.onSurface, fontSize: 13)),
  648. const SizedBox(width: 4),
  649. Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 18),
  650. ],
  651. ),
  652. ),
  653. const SizedBox(width: 12),
  654. Expanded(
  655. child: SizedBox(
  656. height: 34,
  657. child: TextField(
  658. controller: widget.controller,
  659. focusNode: _focusNode,
  660. onChanged: widget.onChanged,
  661. onSubmitted: (_) => _focusNode.unfocus(),
  662. style: const TextStyle(fontSize: 13),
  663. decoration: InputDecoration(
  664. hintText: l10n.searchNickname,
  665. hintStyle: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  666. prefixIcon: Icon(Icons.search, size: 16, color: cs.onSurface.withAlpha(153)),
  667. contentPadding: EdgeInsets.zero,
  668. isDense: true,
  669. filled: true,
  670. fillColor: cs.onSurface.withAlpha(20),
  671. enabledBorder: noBorder,
  672. focusedBorder: noBorder,
  673. border: noBorder,
  674. ),
  675. ),
  676. ),
  677. ),
  678. ],
  679. ),
  680. );
  681. }
  682. }
  683. // ── 交易员卡片 ───────────────────────────────────────────
  684. class _TraderCard extends ConsumerWidget {
  685. const _TraderCard({required this.trader, this.showFollowButton = true});
  686. final Trader trader;
  687. final bool showFollowButton;
  688. static const _avatarColors = [
  689. Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
  690. Color(0xFFf3ba2f), Color(0xFF2775ca), Color(0xFF00aae4),
  691. ];
  692. Color get _avatarBg =>
  693. _avatarColors[trader.avatarLetter.codeUnitAt(0) % _avatarColors.length];
  694. /// 跟随人数是否已满(且当前用户未跟随)
  695. bool get _isFull =>
  696. !trader.isFollowing &&
  697. trader.maxFollowers != null &&
  698. trader.followers >= trader.maxFollowers!;
  699. void _goDetail(BuildContext context, WidgetRef ref) {
  700. if (trader.id.isEmpty) return;
  701. if (!ref.read(isLoggedInProvider)) {
  702. context.push('/login');
  703. return;
  704. }
  705. context.push('/trader-detail/${trader.id}');
  706. }
  707. Future<void> _handleUnfollow(BuildContext context, WidgetRef ref) async {
  708. if (!ref.read(isLoggedInProvider)) {
  709. context.push('/login');
  710. return;
  711. }
  712. final confirmed = await showConfirmDialog(
  713. context,
  714. content: AppLocalizations.of(context)!.unfollowConfirmMsg,
  715. );
  716. if (!confirmed || !context.mounted) return;
  717. try {
  718. await ref.read(copyTradingRepositoryProvider).unfollowTrader(trader.id);
  719. ref.read(copyTradingProvider.notifier).refresh();
  720. } catch (e) {
  721. if (context.mounted) showTipDialog(context, content: extractErrorMessage(e));
  722. }
  723. }
  724. @override
  725. Widget build(BuildContext context, WidgetRef ref) {
  726. final cs = Theme.of(context).colorScheme;
  727. final l10n = AppLocalizations.of(context)!;
  728. final isDark = Theme.of(context).brightness == Brightness.dark;
  729. final isLoggedIn = ref.watch(isLoggedInProvider);
  730. final isTrader = ref.watch(copyTradingProvider.select((s) => s.isTrader));
  731. return GestureDetector(
  732. onTap: () => _goDetail(context, ref),
  733. child: Container(
  734. margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  735. padding: const EdgeInsets.all(14),
  736. decoration: BoxDecoration(
  737. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  738. borderRadius: BorderRadius.circular(12),
  739. ),
  740. child: Column(
  741. crossAxisAlignment: CrossAxisAlignment.start,
  742. children: [
  743. // 头部:头像 + 名称 + 跟随人数 + 跟单按钮
  744. Row(
  745. children: [
  746. // 头像 + 等级角标
  747. SizedBox(
  748. width: 44,
  749. height: 52, // extra space for level badge
  750. child: Stack(
  751. clipBehavior: Clip.none,
  752. children: [
  753. if (trader.avatarUrl != null && trader.avatarUrl!.isNotEmpty)
  754. ClipOval(
  755. child: Image.network(
  756. trader.avatarUrl!,
  757. width: 44,
  758. height: 44,
  759. fit: BoxFit.cover,
  760. errorBuilder: (_, __, ___) => Container(
  761. width: 44,
  762. height: 44,
  763. decoration: BoxDecoration(color: _avatarBg, shape: BoxShape.circle),
  764. child: Center(
  765. child: Text(trader.avatarLetter,
  766. style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)),
  767. ),
  768. ),
  769. ),
  770. )
  771. else
  772. Container(
  773. width: 44,
  774. height: 44,
  775. decoration: BoxDecoration(color: _avatarBg, shape: BoxShape.circle),
  776. child: Center(
  777. child: Text(
  778. trader.avatarLetter,
  779. style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700),
  780. ),
  781. ),
  782. ),
  783. // 等级角标
  784. if (trader.levelName != null && trader.levelName!.isNotEmpty)
  785. Positioned(
  786. bottom: 0,
  787. left: 0,
  788. right: 0,
  789. child: Center(
  790. child: Container(
  791. padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
  792. decoration: BoxDecoration(
  793. color: AppColors.darkBadgeBg,
  794. borderRadius: BorderRadius.circular(20),
  795. border: Border.all(color: AppColors.darkBgMid),
  796. ),
  797. child: Row(
  798. mainAxisSize: MainAxisSize.min,
  799. children: [
  800. const Icon(Icons.layers, size: 8, color: AppColors.rankPurple),
  801. const SizedBox(width: 2),
  802. Flexible(
  803. child: Text(
  804. trader.levelName!,
  805. style: const TextStyle(
  806. color: AppColors.rankPurple, fontSize: 9, fontWeight: FontWeight.w700),
  807. overflow: TextOverflow.ellipsis,
  808. ),
  809. ),
  810. ],
  811. ),
  812. ),
  813. ),
  814. ),
  815. ],
  816. ),
  817. ),
  818. const SizedBox(width: 10),
  819. Expanded(
  820. child: Column(
  821. crossAxisAlignment: CrossAxisAlignment.start,
  822. children: [
  823. Text(trader.name, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  824. Row(
  825. children: [
  826. Icon(Icons.person_outline, size: 13, color: cs.onSurface.withAlpha(130)),
  827. const SizedBox(width: 3),
  828. Text(
  829. trader.maxFollowers != null
  830. ? '${trader.followers}/${trader.maxFollowers}'
  831. : '${trader.followers}',
  832. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  833. ),
  834. // 满员标签
  835. if (_isFull) ...[
  836. const SizedBox(width: 4),
  837. Container(
  838. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
  839. decoration: BoxDecoration(
  840. color: AppColors.fall.withValues(alpha: 0.12),
  841. borderRadius: BorderRadius.circular(3),
  842. ),
  843. child: Text(l10n.full,
  844. style: const TextStyle(color: AppColors.fall, fontSize: 10, fontWeight: FontWeight.w600)),
  845. ),
  846. ],
  847. ],
  848. ),
  849. ],
  850. ),
  851. ),
  852. if (isLoggedIn && !isTrader) ...[
  853. IconButton(
  854. padding: EdgeInsets.zero,
  855. visualDensity: VisualDensity.compact,
  856. constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
  857. icon: Icon(
  858. trader.isFavorited ? Icons.star_rounded : Icons.star_outline_rounded,
  859. size: 24,
  860. color: trader.isFavorited ? AppColors.brand : cs.onSurface.withAlpha(153),
  861. ),
  862. onPressed: () async {
  863. final next =
  864. await ref.read(copyTradingProvider.notifier).toggleFavorite(trader);
  865. if (!context.mounted) {
  866. return;
  867. }
  868. if (next == true) {
  869. showTopToast(
  870. context,
  871. message: l10n.addedToFavorites,
  872. backgroundColor: const Color(0xFF2ECC71),
  873. );
  874. } else if (next == null) {
  875. showTipDialog(context, content: l10n.operationFailedRetry);
  876. }
  877. },
  878. ),
  879. ],
  880. if (showFollowButton)
  881. trader.isFollowing
  882. ? OutlinedButton(
  883. onPressed: () => _handleUnfollow(context, ref),
  884. style: OutlinedButton.styleFrom(
  885. side: BorderSide(color: cs.onSurface, width: 1.5),
  886. foregroundColor: cs.onSurface,
  887. backgroundColor: cs.surface,
  888. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 0),
  889. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
  890. minimumSize: const Size(0, 32),
  891. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  892. ),
  893. child: Text(l10n.unfollow,
  894. style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
  895. )
  896. : _isFull
  897. ? OutlinedButton(
  898. onPressed: null,
  899. style: OutlinedButton.styleFrom(
  900. side: BorderSide(color: cs.outline.withAlpha(60)),
  901. foregroundColor: cs.onSurface.withAlpha(100),
  902. backgroundColor: Colors.transparent,
  903. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
  904. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  905. minimumSize: const Size(0, 32),
  906. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  907. disabledForegroundColor: cs.onSurface.withAlpha(80),
  908. ),
  909. child: Text(l10n.full, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
  910. )
  911. : ElevatedButton(
  912. onPressed: () => _goDetail(context, ref),
  913. style: ElevatedButton.styleFrom(
  914. backgroundColor: AppColors.brand,
  915. foregroundColor: Colors.black,
  916. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  917. shape: const StadiumBorder(),
  918. minimumSize: Size.zero,
  919. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  920. elevation: 0,
  921. ),
  922. child: Text(l10n.copyTrading, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
  923. ),
  924. ],
  925. ),
  926. // 数据行 1:收益率 / 收益 / 分润比例
  927. Row(
  928. children: [
  929. _StatItem(
  930. label: l10n.twoWeekRoi,
  931. value: trader.roi30d == 0 ? '--' : '${trader.roi30d >= 0 ? '+' : ''}${trader.roi30d.toStringAsFixed(2)}%',
  932. valueColor: trader.roi30d >= 0 ? AppColors.rise : AppColors.fall,
  933. ),
  934. _StatItem(
  935. label: l10n.profitUsdtLabel,
  936. value: trader.profit30d == 0 ? '--' : '${trader.profit30d >= 0 ? '+' : ''}${trader.profit30d.toStringAsFixed(2)}',
  937. valueColor: trader.profit30d >= 0 ? AppColors.rise : AppColors.fall,
  938. ),
  939. _StatItem(
  940. label: l10n.profitShare,
  941. value: trader.profitShare == 0 ? '--' : '${trader.profitShare.toStringAsFixed(0)}%',
  942. ),
  943. ],
  944. ),
  945. const SizedBox(height: 10),
  946. // 数据行 2:近14天走势(标签)+ 迷你图
  947. Divider(height: 1, thickness: 0.5, color: cs.onSurface.withAlpha(35)),
  948. const SizedBox(height: 10),
  949. Row(
  950. children: [
  951. Text(l10n.twoWeekTrend, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  952. const Spacer(),
  953. SizedBox(width: 120, child: _MiniChart(data: trader.trendData, isPositive: trader.roi30d >= 0)),
  954. ],
  955. ),
  956. const SizedBox(height: 10),
  957. Divider(height: 1, thickness: 0.5, color: cs.onSurface.withAlpha(35)),
  958. const SizedBox(height: 10),
  959. // 数据行 3:胜率
  960. Row(
  961. children: [
  962. _StatItem(label: l10n.twoWeekWinRate, value: trader.winRate == 0 ? '--' : '${trader.winRate.toStringAsFixed(1)}%'),
  963. const Expanded(child: SizedBox()),
  964. ],
  965. ),
  966. ],
  967. ),
  968. ),
  969. );
  970. }
  971. }
  972. class _StatItem extends StatelessWidget {
  973. const _StatItem({required this.label, required this.value, this.valueColor});
  974. final String label;
  975. final String value;
  976. final Color? valueColor;
  977. @override
  978. Widget build(BuildContext context) {
  979. final cs = Theme.of(context).colorScheme;
  980. return Expanded(
  981. child: Column(
  982. crossAxisAlignment: CrossAxisAlignment.start,
  983. children: [
  984. Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  985. const SizedBox(height: 2),
  986. Text(value, style: TextStyle(color: valueColor ?? cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600)),
  987. ],
  988. ),
  989. );
  990. }
  991. }
  992. // ── 迷你折线图 ────────────────────────────────────────────
  993. class _MiniChart extends StatelessWidget {
  994. const _MiniChart({required this.data, required this.isPositive});
  995. final List<double> data;
  996. final bool isPositive;
  997. @override
  998. Widget build(BuildContext context) {
  999. if (data.isEmpty) return const SizedBox.shrink();
  1000. return SizedBox(
  1001. height: 36,
  1002. child: CustomPaint(
  1003. painter: _MiniChartPainter(data: data, isPositive: isPositive),
  1004. ),
  1005. );
  1006. }
  1007. }
  1008. class _MiniChartPainter extends CustomPainter {
  1009. const _MiniChartPainter({required this.data, required this.isPositive});
  1010. final List<double> data;
  1011. final bool isPositive;
  1012. @override
  1013. void paint(Canvas canvas, Size size) {
  1014. if (data.length < 2) return;
  1015. final min = data.reduce((a, b) => a < b ? a : b);
  1016. final max = data.reduce((a, b) => a > b ? a : b);
  1017. final range = (max - min).abs();
  1018. // 颜色与近14天收益率标签保持一致(roi30d >= 0 为绿,否则为红)
  1019. final lineColor = isPositive ? AppColors.rise : AppColors.fall;
  1020. // Compute all points
  1021. final points = List.generate(data.length, (i) {
  1022. final x = i / (data.length - 1) * size.width;
  1023. final y = range == 0
  1024. ? size.height / 2
  1025. : (1 - (data[i] - min) / range) * size.height;
  1026. return Offset(x, y);
  1027. });
  1028. final smoothLine = _smoothPath(points);
  1029. // Gradient fill:同样用单调插值路径,首尾封底
  1030. final fillPath = Path()
  1031. ..moveTo(points.first.dx, size.height)
  1032. ..lineTo(points.first.dx, points.first.dy);
  1033. _appendMonotoneCurve(fillPath, points);
  1034. fillPath
  1035. ..lineTo(points.last.dx, size.height)
  1036. ..close();
  1037. canvas.drawPath(
  1038. fillPath,
  1039. Paint()
  1040. ..shader = LinearGradient(
  1041. begin: Alignment.topCenter,
  1042. end: Alignment.bottomCenter,
  1043. colors: [lineColor.withValues(alpha: 0.35), lineColor.withValues(alpha: 0.0)],
  1044. ).createShader(Rect.fromLTWH(0, 0, size.width, size.height))
  1045. ..style = PaintingStyle.fill,
  1046. );
  1047. // Smooth line on top
  1048. canvas.drawPath(
  1049. smoothLine,
  1050. Paint()
  1051. ..color = lineColor
  1052. ..strokeWidth = 1.8
  1053. ..style = PaintingStyle.stroke
  1054. ..strokeCap = StrokeCap.round
  1055. ..strokeJoin = StrokeJoin.round,
  1056. );
  1057. }
  1058. /// 单调三次插值(Fritsch-Carlson),保证曲线不超出数据点范围,不会重叠
  1059. Path _smoothPath(List<Offset> pts) {
  1060. final path = Path()..moveTo(pts.first.dx, pts.first.dy);
  1061. _appendMonotoneCurve(path, pts);
  1062. return path;
  1063. }
  1064. void _appendMonotoneCurve(Path path, List<Offset> pts) {
  1065. final n = pts.length;
  1066. if (n < 2) return;
  1067. // 各段斜率
  1068. final delta = List<double>.generate(n - 1, (i) {
  1069. final dx = pts[i + 1].dx - pts[i].dx;
  1070. return dx == 0 ? 0 : (pts[i + 1].dy - pts[i].dy) / dx;
  1071. });
  1072. // 各点切线斜率(相邻段平均)
  1073. final m = List<double>.filled(n, 0);
  1074. m[0] = delta[0];
  1075. for (var i = 1; i < n - 1; i++) {
  1076. m[i] = (delta[i - 1] + delta[i]) / 2;
  1077. }
  1078. m[n - 1] = delta[n - 2];
  1079. // Fritsch-Carlson 单调性修正:防止斜率过大导致曲线超出范围
  1080. for (var i = 0; i < n - 1; i++) {
  1081. if (delta[i] == 0) {
  1082. m[i] = 0;
  1083. m[i + 1] = 0;
  1084. } else {
  1085. final alpha = m[i] / delta[i];
  1086. final beta = m[i + 1] / delta[i];
  1087. final tau = alpha * alpha + beta * beta;
  1088. if (tau > 9) {
  1089. final t = 3 / math.sqrt(tau);
  1090. m[i] = t * alpha * delta[i];
  1091. m[i + 1] = t * beta * delta[i];
  1092. }
  1093. }
  1094. }
  1095. // 输出三次 Hermite 曲线段
  1096. for (var i = 0; i < n - 1; i++) {
  1097. final dx = pts[i + 1].dx - pts[i].dx;
  1098. final cp1 = Offset(pts[i].dx + dx / 3, pts[i].dy + m[i] * dx / 3);
  1099. final cp2 = Offset(pts[i + 1].dx - dx / 3, pts[i + 1].dy - m[i + 1] * dx / 3);
  1100. path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, pts[i + 1].dx, pts[i + 1].dy);
  1101. }
  1102. }
  1103. @override
  1104. bool shouldRepaint(_MiniChartPainter old) => old.data != data || old.isPositive != isPositive;
  1105. }
  1106. // ── 未登录提示卡 ─────────────────────────────────────────
  1107. class _LoginBanner extends StatelessWidget {
  1108. const _LoginBanner({required this.onLogin, this.embedded = false});
  1109. final VoidCallback onLogin;
  1110. final bool embedded;
  1111. @override
  1112. Widget build(BuildContext context) {
  1113. final cs = Theme.of(context).colorScheme;
  1114. final isDark = Theme.of(context).brightness == Brightness.dark;
  1115. final card = Container(
  1116. margin: embedded ? const EdgeInsets.fromLTRB(16, 0, 16, 0) : const EdgeInsets.fromLTRB(16, 8, 16, 8),
  1117. padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
  1118. decoration: BoxDecoration(
  1119. color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary,
  1120. borderRadius: BorderRadius.circular(16),
  1121. border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
  1122. ),
  1123. child: Row(
  1124. children: [
  1125. Expanded(
  1126. child: Column(
  1127. crossAxisAlignment: CrossAxisAlignment.start,
  1128. children: [
  1129. Text(
  1130. AppLocalizations.of(context)!.loginToViewAccount,
  1131. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600),
  1132. ),
  1133. const SizedBox(height: 4),
  1134. Text(
  1135. AppLocalizations.of(context)!.loginToFollowExpert,
  1136. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  1137. ),
  1138. ],
  1139. ),
  1140. ),
  1141. const SizedBox(width: 12),
  1142. ElevatedButton(
  1143. onPressed: onLogin,
  1144. style: ElevatedButton.styleFrom(
  1145. backgroundColor: AppColors.brand,
  1146. foregroundColor: Colors.black,
  1147. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  1148. shape: const StadiumBorder(),
  1149. minimumSize: Size.zero,
  1150. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  1151. elevation: 0,
  1152. ),
  1153. child: Text(AppLocalizations.of(context)!.loginNow, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
  1154. ),
  1155. ],
  1156. ),
  1157. );
  1158. if (embedded) {
  1159. return Padding(
  1160. padding: const EdgeInsets.fromLTRB(0, 16, 0, 4),
  1161. child: Column(
  1162. crossAxisAlignment: CrossAxisAlignment.start,
  1163. children: [
  1164. Padding(
  1165. padding: const EdgeInsets.fromLTRB(16, 0, 0, 8),
  1166. child: Text(
  1167. AppLocalizations.of(context)!.copyAccountEquity,
  1168. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  1169. ),
  1170. ),
  1171. card,
  1172. ],
  1173. ),
  1174. );
  1175. }
  1176. return card;
  1177. }
  1178. }
  1179. // ── 骨架屏 ────────────────────────────────────────────────
  1180. /// 首次进入时的全页骨架屏(权益卡 + Tab栏 + 列表)
  1181. class _CopyTradingFullSkeleton extends StatelessWidget {
  1182. const _CopyTradingFullSkeleton({required this.pageBg, required this.cardBg});
  1183. final Color pageBg;
  1184. final Color cardBg;
  1185. @override
  1186. Widget build(BuildContext context) {
  1187. return Column(
  1188. children: [
  1189. // 权益卡骨架
  1190. Container(
  1191. color: cardBg,
  1192. padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
  1193. child: AppShimmer(
  1194. child: Row(
  1195. crossAxisAlignment: CrossAxisAlignment.center,
  1196. children: [
  1197. Expanded(
  1198. child: Column(
  1199. crossAxisAlignment: CrossAxisAlignment.start,
  1200. children: [
  1201. shimmerBox(110, 12),
  1202. const SizedBox(height: 10),
  1203. shimmerBox(180, 26),
  1204. ],
  1205. ),
  1206. ),
  1207. shimmerBox(80, 36, radius: 8),
  1208. ],
  1209. ),
  1210. ),
  1211. ),
  1212. Container(height: 8, color: pageBg),
  1213. // Tab + 搜索骨架
  1214. Container(
  1215. color: cardBg,
  1216. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  1217. child: AppShimmer(
  1218. child: Column(
  1219. children: [
  1220. shimmerFill(40, radius: 10),
  1221. const SizedBox(height: 10),
  1222. shimmerFill(34, radius: 20),
  1223. ],
  1224. ),
  1225. ),
  1226. ),
  1227. Container(height: 8, color: pageBg),
  1228. // 列表骨架
  1229. Expanded(
  1230. child: ListView.builder(
  1231. padding: const EdgeInsets.only(top: 4, bottom: 16),
  1232. itemCount: 4,
  1233. itemBuilder: (_, __) => const _TraderCardSkeleton(),
  1234. ),
  1235. ),
  1236. ],
  1237. );
  1238. }
  1239. }
  1240. /// 交易员卡片骨架(与 _TraderCard 布局对应)
  1241. class _TraderCardSkeleton extends StatelessWidget {
  1242. const _TraderCardSkeleton();
  1243. @override
  1244. Widget build(BuildContext context) {
  1245. final isDark = Theme.of(context).brightness == Brightness.dark;
  1246. return AppShimmer(
  1247. child: Container(
  1248. margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  1249. padding: const EdgeInsets.all(14),
  1250. decoration: BoxDecoration(
  1251. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1252. borderRadius: BorderRadius.circular(12),
  1253. ),
  1254. child: Column(
  1255. crossAxisAlignment: CrossAxisAlignment.start,
  1256. children: [
  1257. // 头部:头像 + 名称 + 按钮
  1258. Row(
  1259. children: [
  1260. shimmerCircle(44),
  1261. const SizedBox(width: 10),
  1262. Expanded(
  1263. child: Column(
  1264. crossAxisAlignment: CrossAxisAlignment.start,
  1265. children: [
  1266. shimmerBox(120, 14),
  1267. const SizedBox(height: 6),
  1268. shimmerBox(80, 11),
  1269. ],
  1270. ),
  1271. ),
  1272. shimmerBox(72, 32, radius: 20),
  1273. ],
  1274. ),
  1275. const SizedBox(height: 14),
  1276. // 数据行 1:3 列统计
  1277. Row(
  1278. children: List.generate(3, (i) => Expanded(
  1279. child: Padding(
  1280. padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
  1281. child: Column(
  1282. crossAxisAlignment: CrossAxisAlignment.start,
  1283. children: [
  1284. shimmerBox(55, 11),
  1285. const SizedBox(height: 4),
  1286. shimmerBox(45, 13),
  1287. ],
  1288. ),
  1289. ),
  1290. )),
  1291. ),
  1292. const SizedBox(height: 10),
  1293. shimmerFill(0.5),
  1294. const SizedBox(height: 10),
  1295. // 走势行
  1296. Row(
  1297. children: [
  1298. shimmerBox(70, 11),
  1299. const Spacer(),
  1300. shimmerBox(120, 36),
  1301. ],
  1302. ),
  1303. const SizedBox(height: 10),
  1304. shimmerFill(0.5),
  1305. const SizedBox(height: 10),
  1306. // 数据行 2:2 列
  1307. Row(
  1308. children: [
  1309. Expanded(
  1310. child: Column(
  1311. crossAxisAlignment: CrossAxisAlignment.start,
  1312. children: [
  1313. shimmerBox(60, 11),
  1314. const SizedBox(height: 4),
  1315. shimmerBox(45, 13),
  1316. ],
  1317. ),
  1318. ),
  1319. Expanded(
  1320. child: Column(
  1321. crossAxisAlignment: CrossAxisAlignment.start,
  1322. children: [
  1323. shimmerBox(60, 11),
  1324. const SizedBox(height: 4),
  1325. shimmerBox(45, 13),
  1326. ],
  1327. ),
  1328. ),
  1329. const Expanded(child: SizedBox()),
  1330. ],
  1331. ),
  1332. ],
  1333. ),
  1334. ),
  1335. );
  1336. }
  1337. }
  1338. // ── 排序底部弹层 ──────────────────────────────────────────
  1339. class _SortSheet extends StatelessWidget {
  1340. const _SortSheet({required this.current, required this.onSelect});
  1341. final TraderSort current;
  1342. final ValueChanged<TraderSort> onSelect;
  1343. @override
  1344. Widget build(BuildContext context) {
  1345. final l10n = AppLocalizations.of(context)!;
  1346. final options = [
  1347. (TraderSort.comprehensive, l10n.comprehensiveSort),
  1348. (TraderSort.winRate30d, l10n.twoWeekWinRate),
  1349. (TraderSort.roi30d, l10n.twoWeekRoi),
  1350. ];
  1351. final cs = Theme.of(context).colorScheme;
  1352. return SafeArea(
  1353. child: Column(
  1354. mainAxisSize: MainAxisSize.min,
  1355. children: [
  1356. const SizedBox(height: 8),
  1357. Container(width: 36, height: 4, decoration: BoxDecoration(color: cs.outline.withAlpha(30), borderRadius: BorderRadius.circular(2))),
  1358. const SizedBox(height: 12),
  1359. ...options.map(
  1360. (o) => GestureDetector(
  1361. onTap: () => onSelect(o.$1),
  1362. child: Padding(
  1363. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  1364. child: Row(
  1365. children: [
  1366. Expanded(child: Text(o.$2, style: TextStyle(color: current == o.$1 ? AppColors.brand : cs.onSurface, fontSize: 15))),
  1367. if (current == o.$1) const Icon(Icons.check, color: AppColors.brand, size: 20),
  1368. ],
  1369. ),
  1370. ),
  1371. ),
  1372. ),
  1373. const SizedBox(height: 8),
  1374. ],
  1375. ),
  1376. );
  1377. }
  1378. }