my_trades_screen.dart 97 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677
  1. import 'dart:io';
  2. import 'dart:typed_data';
  3. import 'dart:ui' as ui;
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/rendering.dart';
  6. import 'package:flutter/services.dart';
  7. import 'package:flutter_riverpod/flutter_riverpod.dart';
  8. import 'package:gal/gal.dart';
  9. import 'package:go_router/go_router.dart';
  10. import 'package:path_provider/path_provider.dart';
  11. import 'package:qr_flutter/qr_flutter.dart';
  12. import 'package:share_plus/share_plus.dart';
  13. import '../../../core/l10n/app_localizations.dart';
  14. import '../../../core/utils/avatar_urls.dart';
  15. import '../../../core/network/dio_client.dart';
  16. import '../../../core/theme/app_colors.dart';
  17. import '../../../core/utils/dialog_utils.dart' show extractErrorMessage;
  18. import '../../../core/utils/top_toast.dart';
  19. import '../../../data/repositories/copy_trading_repository.dart';
  20. import '../../../data/services/auth_service.dart';
  21. import '../../../providers/app_provider.dart';
  22. import '../../widgets/common/app_refresh_indicator.dart';
  23. import '../../widgets/common/app_shimmer.dart';
  24. import '../../widgets/common/app_tab_bar.dart';
  25. class MyTradesScreen extends ConsumerStatefulWidget {
  26. const MyTradesScreen({super.key});
  27. @override
  28. ConsumerState<MyTradesScreen> createState() => _MyTradesScreenState();
  29. }
  30. class _MyTradesScreenState extends ConsumerState<MyTradesScreen>
  31. with SingleTickerProviderStateMixin {
  32. late TabController _tabController;
  33. late PageController _pageController;
  34. bool _loadingProfile = true;
  35. Map<String, dynamic>? _traderInfo;
  36. List<Map<String, dynamic>> _tags = [];
  37. int? _followerCount;
  38. bool _loadingFollowers = true;
  39. bool _followersLoaded = false;
  40. List<Map<String, dynamic>> _followers = [];
  41. int _followersPage = 1;
  42. bool _followersHasMore = true;
  43. bool _followersLoadingMore = false;
  44. static const _followersPageSize = 20;
  45. bool _loadingCurrentOrders = true;
  46. bool _currentOrdersLoaded = false;
  47. List<Map<String, dynamic>> _currentOrders = [];
  48. bool _loadingHistoryOrders = true;
  49. bool _historyOrdersLoaded = false;
  50. List<Map<String, dynamic>> _historyOrders = [];
  51. String _traderId = '';
  52. List<String> _traderSymbols = [];
  53. bool _symbolExpanded = true;
  54. @override
  55. void initState() {
  56. super.initState();
  57. _tabController = TabController(length: 3, vsync: this);
  58. _pageController = PageController();
  59. _tabController.addListener(() {
  60. if (!context.mounted) return;
  61. if (_tabController.indexIsChanging) {
  62. _pageController.animateToPage(
  63. _tabController.index,
  64. duration: const Duration(milliseconds: 280),
  65. curve: Curves.easeOut,
  66. );
  67. } else {
  68. _onTabChanged(_tabController.index);
  69. }
  70. });
  71. _pageController.addListener(() {
  72. if (!context.mounted) return;
  73. if (!_pageController.hasClients) return;
  74. final page = _pageController.page!;
  75. final offset = page - _tabController.index;
  76. if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
  77. _tabController.offset = offset.clamp(-1.0, 1.0);
  78. }
  79. });
  80. _loadProfile();
  81. }
  82. @override
  83. void dispose() {
  84. _tabController.dispose();
  85. _pageController.dispose();
  86. super.dispose();
  87. }
  88. Future<void> _loadProfile() async {
  89. if (!context.mounted) return;
  90. setState(() => _loadingProfile = true);
  91. try {
  92. final repo = ref.read(copyTradingRepositoryProvider);
  93. // Step1: traderId 是后续请求的依赖,先串行获取
  94. final followerInfo = await repo.getFollowerInfo();
  95. _traderId = followerInfo?['id']?.toString() ?? '';
  96. // Step2: 全部数据并行加载,skeleton 保持到所有数据就绪
  97. final results = await Future.wait([
  98. _traderId.isNotEmpty // [0] 带单员信息
  99. ? repo.getTraderInfo(_traderId).then<dynamic>((v) => v)
  100. : Future<dynamic>.value(null),
  101. repo.getMyTags().then<dynamic>((v) => v), // [1] 标签
  102. repo
  103. .getMyFollowerCount() // [2] 跟单人数
  104. .then<dynamic>((v) => v)
  105. .catchError((_) => 0 as dynamic),
  106. repo
  107. .getMyFollowers(page: 1, pageSize: _followersPageSize) // [3] 跟单用户
  108. .then<dynamic>((v) => v),
  109. _traderId.isNotEmpty // [4] 当前带单 + 合约持仓
  110. ? Future.wait([
  111. repo.getTraderOrders(traderId: _traderId, type: 'current'),
  112. repo.getFuturesPositions(),
  113. ]).then<dynamic>((v) => v)
  114. : Future<dynamic>.value(null),
  115. _traderId.isNotEmpty // [5] 历史带单
  116. ? repo
  117. .getTraderOrders(traderId: _traderId, type: 'history')
  118. .then<dynamic>((v) => v)
  119. : Future<dynamic>.value(null),
  120. ]);
  121. final info = results[0] as Map<String, dynamic>?;
  122. final tags = results[1] as List<Map<String, dynamic>>;
  123. final followerCount = results[2] as int;
  124. final followersList = results[3] as List<Map<String, dynamic>>;
  125. // 当前带单:enriching with futures position data
  126. List<Map<String, dynamic>> enrichedCurrentOrders = [];
  127. if (_traderId.isNotEmpty && results[4] != null) {
  128. final pair = results[4] as List;
  129. final currentRaw = pair[0] as List<Map<String, dynamic>>;
  130. final futuresPos = pair[1] as List<Map<String, dynamic>>;
  131. final futuresMap = <String, Map<String, dynamic>>{
  132. for (final p in futuresPos)
  133. if (p['id']?.toString().isNotEmpty == true) p['id'].toString(): p,
  134. };
  135. enrichedCurrentOrders = currentRaw.map((o) {
  136. final pid = o['positionId']?.toString() ??
  137. o['traderPositionId']?.toString() ??
  138. '';
  139. final fp = pid.isNotEmpty ? futuresMap[pid] : null;
  140. if (fp == null) return o;
  141. final coin = fp['coin'] as Map<String, dynamic>? ?? {};
  142. final pricePrecision = (coin['coinScale'] as num?)?.toInt() ?? 2;
  143. return <String, dynamic>{
  144. ...o,
  145. 'marginRate': fp['marginRate'],
  146. if (fp['estimatedBlastPrice'] != null)
  147. 'estimatedBlastPrice': fp['estimatedBlastPrice'],
  148. '_pricePrecision': pricePrecision,
  149. };
  150. }).toList();
  151. }
  152. final historyOrders = _traderId.isNotEmpty && results[5] != null
  153. ? results[5] as List<Map<String, dynamic>>
  154. : <Map<String, dynamic>>[];
  155. // 带单合约列表(与交易员详情页一致,标题走 l10n.tradingContracts)
  156. List<String> traderSymbols = [];
  157. if (_traderId.isNotEmpty) {
  158. try {
  159. final symRaw = await repo.getTraderSymbols(_traderId);
  160. traderSymbols = symRaw
  161. .map((s) =>
  162. s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '')
  163. .where((n) => n.isNotEmpty)
  164. .toList()
  165. ..sort();
  166. } catch (_) {}
  167. }
  168. if (context.mounted) {
  169. setState(() {
  170. _traderInfo = info;
  171. _tags = tags;
  172. _followerCount = followerCount;
  173. // 跟单用户
  174. _followers = followersList;
  175. _loadingFollowers = false;
  176. _followersLoaded = true;
  177. _followersHasMore = followersList.length >= _followersPageSize;
  178. // 当前带单
  179. _currentOrders = enrichedCurrentOrders;
  180. _loadingCurrentOrders = false;
  181. _currentOrdersLoaded = true;
  182. // 历史带单
  183. _historyOrders = historyOrders;
  184. _loadingHistoryOrders = false;
  185. _historyOrdersLoaded = true;
  186. _traderSymbols = traderSymbols;
  187. // 最后关掉骨架
  188. _loadingProfile = false;
  189. });
  190. }
  191. } catch (e) {
  192. if (context.mounted) setState(() => _loadingProfile = false);
  193. }
  194. }
  195. /// 仅刷新交易员信息卡(昵称/签名等),不重新加载订单列表
  196. Future<void> _refreshProfile() async {
  197. try {
  198. final repo = ref.read(copyTradingRepositoryProvider);
  199. final followerInfo = await repo.getFollowerInfo();
  200. final traderId = followerInfo?['id']?.toString() ?? '';
  201. if (traderId.isNotEmpty) {
  202. final info = await repo.getTraderInfo(traderId);
  203. List<String> sym = [];
  204. try {
  205. final symRaw = await repo.getTraderSymbols(traderId);
  206. sym = symRaw
  207. .map((s) =>
  208. s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '')
  209. .where((n) => n.isNotEmpty)
  210. .toList()
  211. ..sort();
  212. } catch (_) {}
  213. if (context.mounted) {
  214. setState(() {
  215. _traderId = traderId;
  216. _traderInfo = info;
  217. _traderSymbols = sym;
  218. });
  219. }
  220. }
  221. } catch (_) {}
  222. }
  223. void _onTabChanged(int index) {
  224. switch (index) {
  225. case 0:
  226. if (!_followersLoaded) _loadFollowers();
  227. break;
  228. case 1:
  229. if (!_currentOrdersLoaded) _loadCurrentOrders();
  230. break;
  231. case 2:
  232. if (!_historyOrdersLoaded) _loadHistoryOrders();
  233. break;
  234. }
  235. }
  236. Future<void> _loadFollowers() async {
  237. if (!context.mounted) return;
  238. setState(() {
  239. _loadingFollowers = true;
  240. _followersPage = 1;
  241. _followersHasMore = true;
  242. });
  243. try {
  244. final list = await ref
  245. .read(copyTradingRepositoryProvider)
  246. .getMyFollowers(page: 1, pageSize: _followersPageSize);
  247. if (context.mounted)
  248. setState(() {
  249. _followers = list;
  250. _loadingFollowers = false;
  251. _followersLoaded = true;
  252. _followersHasMore = list.length >= _followersPageSize;
  253. });
  254. } catch (_) {
  255. if (context.mounted)
  256. setState(() {
  257. _loadingFollowers = false;
  258. _followersLoaded = true;
  259. });
  260. }
  261. }
  262. Future<void> _loadMoreFollowers() async {
  263. if (!_followersHasMore || _followersLoadingMore || _loadingFollowers)
  264. return;
  265. final nextPage = _followersPage + 1;
  266. if (!context.mounted) return;
  267. setState(() => _followersLoadingMore = true);
  268. try {
  269. final list = await ref
  270. .read(copyTradingRepositoryProvider)
  271. .getMyFollowers(page: nextPage, pageSize: _followersPageSize);
  272. if (context.mounted)
  273. setState(() {
  274. _followers = [..._followers, ...list];
  275. _followersPage = nextPage;
  276. _followersHasMore = list.length >= _followersPageSize;
  277. _followersLoadingMore = false;
  278. });
  279. } catch (_) {
  280. if (context.mounted) setState(() => _followersLoadingMore = false);
  281. }
  282. }
  283. Future<void> _loadCurrentOrders() async {
  284. if (_traderId.isEmpty) {
  285. if (context.mounted)
  286. setState(() {
  287. _loadingCurrentOrders = false;
  288. _currentOrdersLoaded = true;
  289. });
  290. return;
  291. }
  292. if (!context.mounted) return;
  293. setState(() => _loadingCurrentOrders = true);
  294. try {
  295. final repo = ref.read(copyTradingRepositoryProvider);
  296. final results = await Future.wait([
  297. repo.getTraderOrders(traderId: _traderId, type: 'current'),
  298. repo.getFuturesPositions(),
  299. ]);
  300. final list = results[0];
  301. final futuresPositions = results[1];
  302. // Build positionId → futures position map for cross-referencing
  303. final futuresMap = <String, Map<String, dynamic>>{
  304. for (final p in futuresPositions)
  305. if (p['id']?.toString().isNotEmpty == true) p['id'].toString(): p,
  306. };
  307. // Enrich each copy order with marginRate from the matching futures position
  308. final enriched = list.map((o) {
  309. final pid = o['positionId']?.toString() ??
  310. o['traderPositionId']?.toString() ??
  311. '';
  312. final fp = pid.isNotEmpty ? futuresMap[pid] : null;
  313. if (fp == null) return o;
  314. final coin = fp['coin'] as Map<String, dynamic>? ?? {};
  315. final pricePrecision = (coin['coinScale'] as num?)?.toInt() ?? 2;
  316. return <String, dynamic>{
  317. ...o,
  318. 'marginRate': fp['marginRate'],
  319. if (fp['estimatedBlastPrice'] != null)
  320. 'estimatedBlastPrice': fp['estimatedBlastPrice'],
  321. '_pricePrecision': pricePrecision,
  322. };
  323. }).toList();
  324. if (context.mounted) {
  325. setState(() {
  326. _currentOrders = enriched;
  327. _loadingCurrentOrders = false;
  328. _currentOrdersLoaded = true;
  329. });
  330. }
  331. } catch (_) {
  332. if (context.mounted)
  333. setState(() {
  334. _loadingCurrentOrders = false;
  335. _currentOrdersLoaded = true;
  336. });
  337. }
  338. }
  339. Future<void> _loadHistoryOrders() async {
  340. if (_traderId.isEmpty) {
  341. if (context.mounted)
  342. setState(() {
  343. _loadingHistoryOrders = false;
  344. _historyOrdersLoaded = true;
  345. });
  346. return;
  347. }
  348. if (!context.mounted) return;
  349. setState(() => _loadingHistoryOrders = true);
  350. try {
  351. final list = await ref
  352. .read(copyTradingRepositoryProvider)
  353. .getTraderOrders(traderId: _traderId, type: 'history');
  354. if (context.mounted)
  355. setState(() {
  356. _historyOrders = list;
  357. _loadingHistoryOrders = false;
  358. _historyOrdersLoaded = true;
  359. });
  360. } catch (_) {
  361. if (context.mounted)
  362. setState(() {
  363. _loadingHistoryOrders = false;
  364. _historyOrdersLoaded = true;
  365. });
  366. }
  367. }
  368. Future<void> _removeFollower(String followId) async {
  369. final confirmed = await _showRemoveConfirmDialog();
  370. if (!confirmed || !context.mounted) return;
  371. try {
  372. await ref.read(copyTradingRepositoryProvider).removeFollower(followId);
  373. if (context.mounted) {
  374. setState(() {
  375. _followers.removeWhere((f) => f['id']?.toString() == followId);
  376. if (_followerCount != null && _followerCount! > 0) {
  377. _followerCount = _followerCount! - 1;
  378. }
  379. });
  380. showTopToast(context,
  381. message: AppLocalizations.of(context)!.removedSuccess,
  382. backgroundColor: AppColors.rise);
  383. }
  384. } catch (e) {
  385. if (context.mounted) {
  386. showTopToast(context,
  387. message: extractErrorMessage(e), backgroundColor: AppColors.fall);
  388. }
  389. }
  390. }
  391. Future<bool> _showRemoveConfirmDialog() async {
  392. final cs = Theme.of(context).colorScheme;
  393. final l10n = AppLocalizations.of(context)!;
  394. return await showDialog<bool>(
  395. context: context,
  396. builder: (ctx) => Dialog(
  397. backgroundColor: cs.surface,
  398. shape:
  399. RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
  400. child: Column(
  401. mainAxisSize: MainAxisSize.min,
  402. children: [
  403. const SizedBox(height: 24),
  404. Padding(
  405. padding: const EdgeInsets.symmetric(horizontal: 20),
  406. child: Text(
  407. l10n.confirmRemoveFollower,
  408. style: TextStyle(
  409. color: cs.onSurface,
  410. fontSize: 17,
  411. fontWeight: FontWeight.w600),
  412. ),
  413. ),
  414. const SizedBox(height: 12),
  415. Padding(
  416. padding: const EdgeInsets.symmetric(horizontal: 20),
  417. child: Text(
  418. l10n.removeFollowerMsg,
  419. style: TextStyle(
  420. color: cs.onSurface.withAlpha(180), fontSize: 14),
  421. textAlign: TextAlign.center,
  422. ),
  423. ),
  424. const SizedBox(height: 24),
  425. Divider(
  426. height: 1,
  427. thickness: 1,
  428. color: cs.outlineVariant.withAlpha(60)),
  429. // 两个按钮各占一半
  430. IntrinsicHeight(
  431. child: Row(
  432. children: [
  433. Expanded(
  434. child: GestureDetector(
  435. onTap: () => Navigator.of(ctx).pop(false),
  436. child: Container(
  437. height: 52,
  438. decoration: BoxDecoration(
  439. borderRadius: const BorderRadius.only(
  440. bottomLeft: Radius.circular(16)),
  441. ),
  442. alignment: Alignment.center,
  443. child: Text(l10n.cancelLabel,
  444. style: TextStyle(
  445. color: cs.onSurface.withAlpha(153),
  446. fontSize: 16)),
  447. ),
  448. ),
  449. ),
  450. VerticalDivider(
  451. width: 1,
  452. thickness: 1,
  453. color: cs.outlineVariant.withAlpha(60)),
  454. Expanded(
  455. child: GestureDetector(
  456. onTap: () => Navigator.of(ctx).pop(true),
  457. child: Container(
  458. height: 52,
  459. decoration: const BoxDecoration(
  460. color: AppColors.brand,
  461. borderRadius: BorderRadius.only(
  462. bottomRight: Radius.circular(16)),
  463. ),
  464. alignment: Alignment.center,
  465. child: Text(l10n.confirm,
  466. style: const TextStyle(
  467. color: Colors.white,
  468. fontSize: 16,
  469. fontWeight: FontWeight.w600)),
  470. ),
  471. ),
  472. ),
  473. ],
  474. ),
  475. ),
  476. ],
  477. ),
  478. ),
  479. ) ??
  480. false;
  481. }
  482. /// 格式化数字,向下截断(不四舍五入,对应 Android RoundingMode.DOWN)
  483. String _fmt(dynamic v, {int decimals = 2}) {
  484. if (v == null) return '--';
  485. final str = v.toString().trim();
  486. if (str.isEmpty) return '--';
  487. final d = double.tryParse(str);
  488. if (d == null) return str;
  489. final isNeg = str.startsWith('-');
  490. final absStr = isNeg ? str.substring(1) : str;
  491. final dotIdx = absStr.indexOf('.');
  492. String truncated;
  493. if (decimals == 0 || dotIdx < 0) {
  494. truncated = dotIdx < 0 ? absStr : absStr.substring(0, dotIdx);
  495. } else {
  496. final frac = absStr.substring(dotIdx + 1);
  497. truncated =
  498. '${absStr.substring(0, dotIdx)}.${frac.length >= decimals ? frac.substring(0, decimals) : frac.padRight(decimals, '0')}';
  499. }
  500. final parts = truncated.split('.');
  501. final intFmt = parts[0].replaceAllMapped(
  502. RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},');
  503. final result = decimals > 0
  504. ? '$intFmt.${parts.length > 1 ? parts[1] : '0' * decimals}'
  505. : intFmt;
  506. return isNeg ? '-$result' : result;
  507. }
  508. Widget _buildTradingContractsStrip(ColorScheme cs) {
  509. if (_traderSymbols.isEmpty) {
  510. return const SizedBox.shrink();
  511. }
  512. final isDark = Theme.of(context).brightness == Brightness.dark;
  513. final l10n = AppLocalizations.of(context)!;
  514. return Container(
  515. margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  516. padding: const EdgeInsets.all(16),
  517. decoration: BoxDecoration(
  518. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  519. borderRadius: BorderRadius.circular(12),
  520. ),
  521. child: Column(
  522. crossAxisAlignment: CrossAxisAlignment.start,
  523. children: [
  524. GestureDetector(
  525. onTap: () => setState(() => _symbolExpanded = !_symbolExpanded),
  526. behavior: HitTestBehavior.opaque,
  527. child: Row(
  528. children: [
  529. Text(
  530. l10n.tradingContracts,
  531. style: TextStyle(
  532. color: cs.onSurface,
  533. fontSize: 14,
  534. fontWeight: FontWeight.w700,
  535. ),
  536. ),
  537. const Spacer(),
  538. Icon(
  539. _symbolExpanded
  540. ? Icons.keyboard_arrow_up
  541. : Icons.keyboard_arrow_down,
  542. color: cs.onSurface.withAlpha(153),
  543. size: 20,
  544. ),
  545. ],
  546. ),
  547. ),
  548. if (_symbolExpanded) ...[
  549. const SizedBox(height: 10),
  550. Wrap(
  551. spacing: 8,
  552. runSpacing: 8,
  553. children: _traderSymbols.map((sym) {
  554. return Container(
  555. padding:
  556. const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
  557. decoration: BoxDecoration(
  558. color: AppColors.brand.withValues(alpha: 0.12),
  559. borderRadius: BorderRadius.circular(8),
  560. border: Border.all(
  561. color: AppColors.brand.withValues(alpha: 0.6),
  562. width: 1.5,
  563. ),
  564. ),
  565. child: Text(
  566. sym,
  567. style: const TextStyle(
  568. color: AppColors.brand,
  569. fontSize: 13,
  570. fontWeight: FontWeight.w600,
  571. ),
  572. ),
  573. );
  574. }).toList(),
  575. ),
  576. ],
  577. ],
  578. ),
  579. );
  580. }
  581. @override
  582. Widget build(BuildContext context) {
  583. ref.watch(localeProvider);
  584. final cs = Theme.of(context).colorScheme;
  585. final l10n = AppLocalizations.of(context)!;
  586. return Scaffold(
  587. appBar: AppBar(
  588. leading: IconButton(
  589. icon: const Icon(Icons.arrow_back_ios, size: 18),
  590. onPressed: () => context.pop(),
  591. ),
  592. title: Text(l10n.myTrades,
  593. style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
  594. actions: [
  595. IconButton(
  596. icon: Icon(Icons.settings_outlined, color: cs.onSurface),
  597. onPressed: () => context.push('/trader-settings').then((_) {
  598. if (context.mounted) _refreshProfile();
  599. }),
  600. ),
  601. ],
  602. ),
  603. body: _loadingProfile
  604. ? const _MyTradesFullSkeleton()
  605. : Column(
  606. children: [
  607. // ── 交易员信息卡 ──────────────────────────
  608. _ProfileCard(traderInfo: _traderInfo, fmt: _fmt, tags: _tags),
  609. _buildTradingContractsStrip(cs),
  610. // ── Tab 栏 ────────────────────────────────
  611. Container(
  612. decoration: BoxDecoration(
  613. border: Border(
  614. bottom: BorderSide(
  615. color: cs.outlineVariant.withAlpha(60), width: 1)),
  616. ),
  617. child: TabBar(
  618. controller: _tabController,
  619. indicator: StretchTabIndicator(
  620. controller: _tabController,
  621. color: AppColors.brand,
  622. ),
  623. indicatorSize: TabBarIndicatorSize.tab,
  624. dividerColor: Colors.transparent,
  625. tabs: [
  626. Tab(
  627. text: (_followerCount != null && _followerCount! > 0)
  628. ? '${l10n.followersTab}($_followerCount)'
  629. : l10n.followersTab),
  630. Tab(text: l10n.currentCopyOrders),
  631. Tab(text: l10n.historyCopyOrders),
  632. ],
  633. ),
  634. ),
  635. // ── Tab 内容(PageView 支持左右滑动)─────────────
  636. Expanded(
  637. child: PageView(
  638. controller: _pageController,
  639. physics: const BouncingScrollPhysics(
  640. parent: AlwaysScrollableScrollPhysics(),
  641. ),
  642. onPageChanged: (index) {
  643. if (_tabController.indexIsChanging) return;
  644. _tabController.index = index;
  645. },
  646. children: [
  647. _FollowersTab(
  648. loading: _loadingFollowers,
  649. loaded: _followersLoaded,
  650. followers: _followers,
  651. hasMore: _followersHasMore,
  652. loadingMore: _followersLoadingMore,
  653. onRemove: _removeFollower,
  654. fmt: _fmt,
  655. onRefresh: _loadFollowers,
  656. onLoadMore: _loadMoreFollowers,
  657. ),
  658. _OrdersTab(
  659. loading: _loadingCurrentOrders,
  660. loaded: _currentOrdersLoaded,
  661. orders: _currentOrders,
  662. onLoad: _loadCurrentOrders,
  663. fmt: _fmt,
  664. onRefresh: _loadCurrentOrders,
  665. ),
  666. _OrdersTab(
  667. loading: _loadingHistoryOrders,
  668. loaded: _historyOrdersLoaded,
  669. orders: _historyOrders,
  670. onLoad: _loadHistoryOrders,
  671. fmt: _fmt,
  672. isHistory: true,
  673. onRefresh: _loadHistoryOrders,
  674. ),
  675. ],
  676. ),
  677. ),
  678. ],
  679. ),
  680. );
  681. }
  682. }
  683. // ── 交易员信息卡 ──────────────────────────────────────────
  684. class _ProfileCard extends StatelessWidget {
  685. const _ProfileCard(
  686. {required this.traderInfo, required this.fmt, this.tags = const []});
  687. final Map<String, dynamic>? traderInfo;
  688. final String Function(dynamic, {int decimals}) fmt;
  689. final List<Map<String, dynamic>> tags;
  690. String get _nickname => traderInfo?['nickname']?.toString() ?? '--';
  691. String get _description => traderInfo?['description']?.toString() ?? '';
  692. String get _levelName => traderInfo?['levelName']?.toString() ?? '';
  693. String? get _avatarUrl => traderInfo == null
  694. ? null
  695. : resolvedAvatarUrlFromRecord(Map<String, dynamic>.from(traderInfo!));
  696. String get _followingCurrent => traderInfo?['following']?.toString() ?? '--';
  697. String get _followingMax => traderInfo?['maxFollow']?.toString() ?? '--';
  698. String get _joinDays =>
  699. traderInfo?['registerDays']?.toString() ??
  700. traderInfo?['settledDays']?.toString() ??
  701. '--';
  702. String get _moneyStrength => traderInfo?['moneyStrength']?.toString() ?? '--';
  703. String get _cumulativeProfit =>
  704. fmt(traderInfo?['profitAmount'] ?? traderInfo?['totalFollowProfit']);
  705. String get _cumulativeFollowers =>
  706. traderInfo?['followCustomer']?.toString() ?? '--';
  707. String get _totalTradeDays => traderInfo?['tradingDays']?.toString() ?? '--';
  708. @override
  709. Widget build(BuildContext context) {
  710. final cs = Theme.of(context).colorScheme;
  711. final isDark = Theme.of(context).brightness == Brightness.dark;
  712. final letter = _nickname.isNotEmpty ? _nickname[0].toUpperCase() : 'T';
  713. return Container(
  714. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  715. padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
  716. child: Column(
  717. crossAxisAlignment: CrossAxisAlignment.start,
  718. children: [
  719. // 头像 + 昵称 + 描述
  720. Row(
  721. crossAxisAlignment: CrossAxisAlignment.start,
  722. children: [
  723. _Avatar(
  724. letter: letter, levelName: _levelName, avatarUrl: _avatarUrl),
  725. const SizedBox(width: 12),
  726. Expanded(
  727. child: Column(
  728. crossAxisAlignment: CrossAxisAlignment.start,
  729. children: [
  730. Text(_nickname,
  731. style: TextStyle(
  732. color: cs.onSurface,
  733. fontSize: 16,
  734. fontWeight: FontWeight.w700)),
  735. if (_description.isNotEmpty)
  736. Padding(
  737. padding: const EdgeInsets.only(top: 4),
  738. child: Text(
  739. _description,
  740. style: TextStyle(
  741. color: cs.onSurface.withAlpha(153), fontSize: 13),
  742. maxLines: 2,
  743. overflow: TextOverflow.ellipsis,
  744. ),
  745. ),
  746. if (tags.isNotEmpty)
  747. Padding(
  748. padding: const EdgeInsets.only(top: 8),
  749. child: SingleChildScrollView(
  750. scrollDirection: Axis.horizontal,
  751. child: Row(
  752. children: tags.map((tag) {
  753. final name = tag['name']?.toString() ?? '';
  754. if (name.isEmpty) return const SizedBox.shrink();
  755. return Padding(
  756. padding: const EdgeInsets.only(right: 6),
  757. child: Container(
  758. padding: const EdgeInsets.symmetric(
  759. horizontal: 10, vertical: 3),
  760. decoration: BoxDecoration(
  761. color: AppColors.tagBlueBg,
  762. borderRadius: BorderRadius.circular(20),
  763. ),
  764. child: Text(
  765. name,
  766. style: const TextStyle(
  767. color: AppColors.tagBlue, fontSize: 12),
  768. ),
  769. ),
  770. );
  771. }).toList(),
  772. ),
  773. ),
  774. ),
  775. ],
  776. ),
  777. ),
  778. ],
  779. ),
  780. const SizedBox(height: 16),
  781. // 统计网格:浅灰色背景圆角卡片(无边框)
  782. Builder(builder: (context) {
  783. final l10n = AppLocalizations.of(context)!;
  784. return Container(
  785. padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
  786. decoration: BoxDecoration(
  787. color: cs.onSurface.withAlpha(10),
  788. borderRadius: BorderRadius.circular(10),
  789. ),
  790. child: Column(
  791. children: [
  792. // 第1行
  793. Row(
  794. children: [
  795. _StatCell(
  796. label: l10n.currentFollowersLabel,
  797. value: _followingCurrent,
  798. valueSuffix: ' / $_followingMax',
  799. ),
  800. _StatCell(
  801. label: l10n.settledDaysTitle,
  802. value: _joinDays,
  803. alignCenter: true),
  804. _StatCell(
  805. label: l10n.fundStrength,
  806. value: '$_moneyStrength USDT',
  807. alignEnd: true),
  808. ],
  809. ),
  810. const SizedBox(height: 12),
  811. // 第2行
  812. Row(
  813. children: [
  814. _StatCell(
  815. label: l10n.cumCopyProfitUsdt,
  816. value: _cumulativeProfit),
  817. _StatCell(
  818. label: l10n.cumFollowerCount,
  819. value: _cumulativeFollowers,
  820. alignCenter: true),
  821. _StatCell(
  822. label: l10n.cumTradingDays,
  823. value: _totalTradeDays,
  824. alignEnd: true),
  825. ],
  826. ),
  827. ],
  828. ),
  829. );
  830. }),
  831. ],
  832. ),
  833. );
  834. }
  835. }
  836. class _Avatar extends StatelessWidget {
  837. const _Avatar(
  838. {required this.letter, required this.levelName, this.avatarUrl});
  839. final String letter;
  840. final String levelName;
  841. final String? avatarUrl;
  842. @override
  843. Widget build(BuildContext context) {
  844. final hasAvatar = avatarUrl != null && avatarUrl!.isNotEmpty;
  845. return SizedBox(
  846. width: 54,
  847. height: 64,
  848. child: Stack(
  849. alignment: Alignment.topCenter,
  850. children: [
  851. if (hasAvatar)
  852. ClipOval(
  853. child: Image.network(
  854. avatarUrl!,
  855. width: 54,
  856. height: 54,
  857. fit: BoxFit.cover,
  858. errorBuilder: (_, __, ___) => _LetterAvatar(letter: letter),
  859. ),
  860. )
  861. else
  862. _LetterAvatar(letter: letter),
  863. if (levelName.isNotEmpty)
  864. Positioned(
  865. bottom: 0,
  866. child: Container(
  867. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  868. decoration: BoxDecoration(
  869. color: AppColors.brand,
  870. borderRadius: BorderRadius.circular(10),
  871. ),
  872. child: Text(levelName,
  873. style: const TextStyle(
  874. color: Colors.black,
  875. fontSize: 10,
  876. fontWeight: FontWeight.w700)),
  877. ),
  878. ),
  879. ],
  880. ),
  881. );
  882. }
  883. }
  884. class _LetterAvatar extends StatelessWidget {
  885. const _LetterAvatar({required this.letter});
  886. final String letter;
  887. @override
  888. Widget build(BuildContext context) {
  889. return Container(
  890. width: 54,
  891. height: 54,
  892. decoration:
  893. const BoxDecoration(color: Color(0xFF5B7BE8), shape: BoxShape.circle),
  894. child: Center(
  895. child: Text(letter,
  896. style: const TextStyle(
  897. color: Colors.white,
  898. fontSize: 22,
  899. fontWeight: FontWeight.w700))),
  900. );
  901. }
  902. }
  903. class _StatCell extends StatelessWidget {
  904. const _StatCell(
  905. {required this.label,
  906. required this.value,
  907. this.valueSuffix,
  908. this.alignEnd = false,
  909. this.alignCenter = false});
  910. final String label;
  911. final String value;
  912. final String? valueSuffix; // 灰色后缀,如 " / 300"
  913. final bool alignEnd;
  914. final bool alignCenter;
  915. @override
  916. Widget build(BuildContext context) {
  917. final cs = Theme.of(context).colorScheme;
  918. final align = alignEnd
  919. ? CrossAxisAlignment.end
  920. : alignCenter
  921. ? CrossAxisAlignment.center
  922. : CrossAxisAlignment.start;
  923. final greyColor = cs.onSurface.withAlpha(120);
  924. return Expanded(
  925. child: Column(
  926. crossAxisAlignment: align,
  927. children: [
  928. Text(label, style: TextStyle(color: greyColor, fontSize: 11)),
  929. const SizedBox(height: 3),
  930. valueSuffix != null
  931. ? RichText(
  932. text: TextSpan(
  933. style: TextStyle(
  934. fontSize: 14,
  935. fontWeight: FontWeight.w600,
  936. color: cs.onSurface),
  937. children: [
  938. TextSpan(text: value),
  939. TextSpan(
  940. text: valueSuffix,
  941. style: TextStyle(
  942. color: greyColor, fontWeight: FontWeight.w400)),
  943. ],
  944. ),
  945. )
  946. : Text(value,
  947. style: TextStyle(
  948. color: cs.onSurface,
  949. fontSize: 14,
  950. fontWeight: FontWeight.w600)),
  951. ],
  952. ),
  953. );
  954. }
  955. }
  956. // ── 跟单用户 Tab ──────────────────────────────────────────
  957. class _FollowersTab extends StatelessWidget {
  958. const _FollowersTab({
  959. required this.loading,
  960. required this.loaded,
  961. required this.followers,
  962. required this.hasMore,
  963. required this.loadingMore,
  964. required this.onRemove,
  965. required this.fmt,
  966. required this.onRefresh,
  967. required this.onLoadMore,
  968. });
  969. final bool loading;
  970. final bool loaded;
  971. final List<Map<String, dynamic>> followers;
  972. final bool hasMore;
  973. final bool loadingMore;
  974. final void Function(String id) onRemove;
  975. final String Function(dynamic, {int decimals}) fmt;
  976. final Future<void> Function() onRefresh;
  977. final VoidCallback onLoadMore;
  978. @override
  979. Widget build(BuildContext context) {
  980. final cs = Theme.of(context).colorScheme;
  981. final isLoading = !loaded || (loading && followers.isEmpty);
  982. return NotificationListener<ScrollNotification>(
  983. onNotification: (n) {
  984. if (!isLoading &&
  985. n is ScrollEndNotification &&
  986. n.metrics.pixels >= n.metrics.maxScrollExtent - 200) {
  987. onLoadMore();
  988. }
  989. return false;
  990. },
  991. child: AppRefreshIndicator(
  992. onRefresh: onRefresh,
  993. child: ListView.builder(
  994. physics: const AlwaysScrollableScrollPhysics(),
  995. padding: const EdgeInsets.only(bottom: 16),
  996. itemCount:
  997. isLoading ? 4 : (followers.isEmpty ? 1 : followers.length + 1),
  998. itemBuilder: (_, i) {
  999. if (isLoading) return const _FollowerCardSkeleton();
  1000. if (followers.isEmpty) {
  1001. return SizedBox(
  1002. height: 200,
  1003. child: Center(
  1004. child: Text(AppLocalizations.of(context)!.noFollowers,
  1005. style: TextStyle(color: cs.onSurface.withAlpha(100))),
  1006. ),
  1007. );
  1008. }
  1009. if (i >= followers.length) {
  1010. if (loadingMore) {
  1011. return const Padding(
  1012. padding: EdgeInsets.symmetric(vertical: 16),
  1013. child: Center(
  1014. child: CircularProgressIndicator(
  1015. color: AppColors.brand, strokeWidth: 2)),
  1016. );
  1017. }
  1018. if (!hasMore) {
  1019. return Padding(
  1020. padding: const EdgeInsets.symmetric(vertical: 16),
  1021. child: Center(
  1022. child: Text(AppLocalizations.of(context)!.noMore,
  1023. style: TextStyle(
  1024. color: cs.onSurface.withAlpha(100),
  1025. fontSize: 12))),
  1026. );
  1027. }
  1028. return const SizedBox(height: 16);
  1029. }
  1030. return _FollowerCard(
  1031. follower: followers[i], onRemove: onRemove, fmt: fmt);
  1032. },
  1033. ),
  1034. ),
  1035. );
  1036. }
  1037. }
  1038. class _FollowerCard extends StatelessWidget {
  1039. const _FollowerCard(
  1040. {required this.follower, required this.onRemove, required this.fmt});
  1041. final Map<String, dynamic> follower;
  1042. final void Function(String) onRemove;
  1043. final String Function(dynamic, {int decimals}) fmt;
  1044. static const _avatarColors = [
  1045. Color(0xFF5B7BE8),
  1046. Color(0xFFf7931a),
  1047. Color(0xFF9945ff),
  1048. Color(0xFFf3ba2f),
  1049. Color(0xFF2775ca),
  1050. Color(0xFF00aae4),
  1051. ];
  1052. @override
  1053. Widget build(BuildContext context) {
  1054. final cs = Theme.of(context).colorScheme;
  1055. final isDark = Theme.of(context).brightness == Brightness.dark;
  1056. final uid = follower['id']?.toString() ?? '';
  1057. final nickname = follower['nickname']?.toString() ?? uid;
  1058. final display = nickname.isNotEmpty ? nickname : uid;
  1059. final colorIdx =
  1060. uid.isNotEmpty ? uid.codeUnitAt(0) % _avatarColors.length : 0;
  1061. final letter = display.isNotEmpty ? display[0].toUpperCase() : '?';
  1062. // 跟随人数
  1063. final following = follower['following']?.toString();
  1064. final maxFollow = follower['maxFollow']?.toString();
  1065. final hasFollowInfo = following != null || maxFollow != null;
  1066. return Container(
  1067. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1068. padding: const EdgeInsets.all(14),
  1069. decoration: BoxDecoration(
  1070. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  1071. borderRadius: BorderRadius.circular(12),
  1072. boxShadow: [
  1073. BoxShadow(
  1074. color: Colors.black.withAlpha(15),
  1075. blurRadius: 8,
  1076. offset: const Offset(0, 2)),
  1077. ],
  1078. ),
  1079. child: Column(
  1080. crossAxisAlignment: CrossAxisAlignment.start,
  1081. children: [
  1082. // 头部:头像 + 昵称+跟随人数 + 移除按钮
  1083. Row(
  1084. crossAxisAlignment: CrossAxisAlignment.start,
  1085. children: [
  1086. Container(
  1087. width: 44,
  1088. height: 44,
  1089. decoration: BoxDecoration(
  1090. color: _avatarColors[colorIdx], shape: BoxShape.circle),
  1091. child: Center(
  1092. child: Text(letter,
  1093. style: const TextStyle(
  1094. color: Colors.white,
  1095. fontSize: 18,
  1096. fontWeight: FontWeight.w700)),
  1097. ),
  1098. ),
  1099. const SizedBox(width: 12),
  1100. Expanded(
  1101. child: Column(
  1102. crossAxisAlignment: CrossAxisAlignment.start,
  1103. children: [
  1104. Text(
  1105. display.isNotEmpty
  1106. ? display
  1107. : AppLocalizations.of(context)!.copyUser,
  1108. style: TextStyle(
  1109. color: cs.onSurface,
  1110. fontSize: 15,
  1111. fontWeight: FontWeight.w600),
  1112. ),
  1113. if (hasFollowInfo) ...[
  1114. const SizedBox(height: 4),
  1115. Row(
  1116. children: [
  1117. Icon(Icons.people_outline,
  1118. size: 13, color: cs.onSurface.withAlpha(120)),
  1119. const SizedBox(width: 4),
  1120. Text(
  1121. maxFollow != null
  1122. ? AppLocalizations.of(context)!
  1123. .followersMaxLabel(
  1124. following ?? '--', maxFollow)
  1125. : AppLocalizations.of(context)!
  1126. .followersCountLabel(following ?? '--'),
  1127. style: TextStyle(
  1128. color: cs.onSurface.withAlpha(153),
  1129. fontSize: 12),
  1130. ),
  1131. ],
  1132. ),
  1133. ],
  1134. ],
  1135. ),
  1136. ),
  1137. const SizedBox(width: 8),
  1138. OutlinedButton(
  1139. onPressed: () => onRemove(uid),
  1140. style: OutlinedButton.styleFrom(
  1141. side: BorderSide(color: cs.onSurface, width: 1.5),
  1142. foregroundColor: cs.onSurface,
  1143. padding:
  1144. const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
  1145. shape: RoundedRectangleBorder(
  1146. borderRadius: BorderRadius.circular(20)),
  1147. minimumSize: Size.zero,
  1148. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  1149. ),
  1150. child: Text(AppLocalizations.of(context)!.remove,
  1151. style: const TextStyle(
  1152. fontSize: 12, fontWeight: FontWeight.w500)),
  1153. ),
  1154. ],
  1155. ),
  1156. const SizedBox(height: 12),
  1157. Divider(
  1158. height: 1,
  1159. thickness: 0.5,
  1160. color: cs.outlineVariant.withAlpha(80)),
  1161. const SizedBox(height: 12),
  1162. // 底部统计行(含竖向分隔线)
  1163. Builder(builder: (context) {
  1164. final l10n = AppLocalizations.of(context)!;
  1165. return IntrinsicHeight(
  1166. child: Row(
  1167. children: [
  1168. _FollowerStat(
  1169. label: l10n.accountEquityUsdt,
  1170. value:
  1171. fmt(follower['balance'] ?? follower['totalBalance'])),
  1172. VerticalDivider(
  1173. width: 1,
  1174. thickness: 0.8,
  1175. color: Colors.grey.withAlpha(100)),
  1176. _FollowerStat(
  1177. label: l10n.cumProfitShareUsdt,
  1178. value: fmt(follower['totalProfitSharing']),
  1179. alignCenter: true),
  1180. VerticalDivider(
  1181. width: 1,
  1182. thickness: 0.8,
  1183. color: Colors.grey.withAlpha(100)),
  1184. _FollowerStat(
  1185. label: l10n.lastProfitShare,
  1186. value: fmt(follower['lastProfitSharing']),
  1187. alignEnd: true),
  1188. ],
  1189. ),
  1190. );
  1191. }),
  1192. // 跟随时间
  1193. Builder(builder: (context) {
  1194. final followTime = follower['followTime']?.toString() ?? '';
  1195. if (followTime.isEmpty) return const SizedBox.shrink();
  1196. return Padding(
  1197. padding: const EdgeInsets.only(top: 8),
  1198. child: Row(
  1199. children: [
  1200. Icon(Icons.access_time,
  1201. size: 12, color: cs.onSurface.withAlpha(100)),
  1202. const SizedBox(width: 4),
  1203. Text(
  1204. '${AppLocalizations.of(context)!.followerFollowTime}:$followTime',
  1205. style: TextStyle(
  1206. color: cs.onSurface.withAlpha(153), fontSize: 12),
  1207. ),
  1208. ],
  1209. ),
  1210. );
  1211. }),
  1212. ],
  1213. ),
  1214. );
  1215. }
  1216. }
  1217. class _FollowerStat extends StatelessWidget {
  1218. const _FollowerStat(
  1219. {required this.label,
  1220. required this.value,
  1221. this.alignEnd = false,
  1222. this.alignCenter = false});
  1223. final String label;
  1224. final String value;
  1225. final bool alignEnd;
  1226. final bool alignCenter;
  1227. @override
  1228. Widget build(BuildContext context) {
  1229. final cs = Theme.of(context).colorScheme;
  1230. final align = alignEnd
  1231. ? CrossAxisAlignment.end
  1232. : alignCenter
  1233. ? CrossAxisAlignment.center
  1234. : CrossAxisAlignment.start;
  1235. return Expanded(
  1236. child: Column(
  1237. crossAxisAlignment: align,
  1238. children: [
  1239. Text(label,
  1240. style:
  1241. TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1242. const SizedBox(height: 2),
  1243. Text(value,
  1244. style: TextStyle(
  1245. color: cs.onSurface,
  1246. fontSize: 13,
  1247. fontWeight: FontWeight.w600)),
  1248. ],
  1249. ),
  1250. );
  1251. }
  1252. }
  1253. // ── 带单仓位 Tab ──────────────────────────────────────────
  1254. class _OrdersTab extends StatelessWidget {
  1255. const _OrdersTab({
  1256. required this.loading,
  1257. required this.loaded,
  1258. required this.orders,
  1259. required this.onLoad,
  1260. required this.fmt,
  1261. required this.onRefresh,
  1262. this.isHistory = false,
  1263. });
  1264. final bool loading;
  1265. final bool loaded;
  1266. final List<Map<String, dynamic>> orders;
  1267. final VoidCallback onLoad;
  1268. final String Function(dynamic, {int decimals}) fmt;
  1269. final Future<void> Function() onRefresh;
  1270. final bool isHistory;
  1271. @override
  1272. Widget build(BuildContext context) {
  1273. final cs = Theme.of(context).colorScheme;
  1274. final isLoading = !loaded || (loading && orders.isEmpty);
  1275. return AppRefreshIndicator(
  1276. onRefresh: onRefresh,
  1277. child: ListView.builder(
  1278. physics: const AlwaysScrollableScrollPhysics(),
  1279. padding: const EdgeInsets.only(bottom: 16),
  1280. itemCount: isLoading ? 4 : (orders.isEmpty ? 1 : orders.length),
  1281. itemBuilder: (_, i) {
  1282. if (isLoading) return const _OrderCardSkeleton();
  1283. if (orders.isEmpty) {
  1284. return SizedBox(
  1285. height: 200,
  1286. child: Center(
  1287. child: Text(
  1288. isHistory
  1289. ? AppLocalizations.of(context)!.noHistoryTrades
  1290. : AppLocalizations.of(context)!.noCurrentTrades,
  1291. style: TextStyle(color: cs.onSurface.withAlpha(100)),
  1292. ),
  1293. ),
  1294. );
  1295. }
  1296. if (i < 0 || i >= orders.length) return const SizedBox.shrink();
  1297. return _OrderCard(order: orders[i], fmt: fmt, isHistory: isHistory);
  1298. },
  1299. ),
  1300. );
  1301. }
  1302. }
  1303. class _OrderCard extends StatelessWidget {
  1304. const _OrderCard(
  1305. {required this.order, required this.fmt, this.isHistory = false});
  1306. final Map<String, dynamic> order;
  1307. final String Function(dynamic, {int decimals}) fmt;
  1308. final bool isHistory;
  1309. /// 按交易对价格精度截断(RoundingMode.DOWN),去除尾部零,与安卓 coinScale 逻辑一致
  1310. String _fmtWithScale(double v, int scale) {
  1311. if (scale < 0) scale = 0;
  1312. final factor = scale == 0
  1313. ? 1.0
  1314. : List.generate(scale, (_) => 10).fold(1.0, (a, b) => a * b);
  1315. final truncated = v >= 0
  1316. ? (v * factor).floorToDouble() / factor
  1317. : (v * factor).ceilToDouble() / factor;
  1318. String s = truncated.toStringAsFixed(scale);
  1319. if (s.contains('.')) {
  1320. s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
  1321. }
  1322. return s.isEmpty ? '0' : s;
  1323. }
  1324. String _fmtTimestamp(dynamic ts) {
  1325. if (ts == null) return '--';
  1326. final ms = int.tryParse(ts.toString());
  1327. if (ms == null) return ts.toString();
  1328. // 后台时间戳为 UTC 毫秒,统一转为 UTC+8(北京时间)展示
  1329. final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
  1330. .add(const Duration(hours: 8));
  1331. final y = dt.year;
  1332. final mo = dt.month.toString().padLeft(2, '0');
  1333. final d = dt.day.toString().padLeft(2, '0');
  1334. final h = dt.hour.toString().padLeft(2, '0');
  1335. final mi = dt.minute.toString().padLeft(2, '0');
  1336. final s = dt.second.toString().padLeft(2, '0');
  1337. return '$y-$mo-$d $h:$mi:$s';
  1338. }
  1339. /// 数量字段专用:4位小数、向下截断、去尾零(对应 Android textDigital=4 + stripTrailingZeros)
  1340. String _fmtQty(dynamic v) {
  1341. final s = fmt(v, decimals: 4);
  1342. if (s == '--' || !s.contains('.')) return s;
  1343. return s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
  1344. }
  1345. @override
  1346. Widget build(BuildContext context) {
  1347. return isHistory ? _buildHistory(context) : _buildCurrent(context);
  1348. }
  1349. // ── 当前带单卡片 ────────────────────────────────────────
  1350. Widget _buildCurrent(BuildContext context) {
  1351. final cs = Theme.of(context).colorScheme;
  1352. final isDark = Theme.of(context).brightness == Brightness.dark;
  1353. final l10n = AppLocalizations.of(context)!;
  1354. final symbol = order['symbol']?.toString() ?? '--';
  1355. // 提取基础币种:BTC/USDT → BTC
  1356. final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol;
  1357. final isLong = (order['direction']?.toString() ?? '0') == '0';
  1358. final leverage = order['leverage']?.toString() ?? '--';
  1359. final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
  1360. final profitColor = profit >= 0 ? AppColors.rise : AppColors.fall;
  1361. final profitRateRaw =
  1362. double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
  1363. final profitRateStr =
  1364. '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
  1365. final profitRateColor =
  1366. profitRateRaw >= 0 ? AppColors.rise : AppColors.fall;
  1367. final openTime = _fmtTimestamp(order['openTime']);
  1368. final positionId = order['positionId']?.toString() ??
  1369. order['traderPositionId']?.toString() ??
  1370. '--';
  1371. // ── 保证金比率:参照合约持仓页公式 principalAmount / (totalPosition * currentPrice) * 100
  1372. // 若 API 直接返回 marginRate 则优先使用
  1373. final principalAmount =
  1374. double.tryParse(order['principalAmount']?.toString() ?? '0') ?? 0.0;
  1375. final apiMarginRate =
  1376. double.tryParse(order['marginRate']?.toString() ?? '');
  1377. final currentPrice =
  1378. double.tryParse(order['currentPrice']?.toString() ?? '0') ?? 0.0;
  1379. final qty =
  1380. double.tryParse(order['totalPosition']?.toString() ?? '0') ?? 0.0;
  1381. String marginRatioStr;
  1382. if (apiMarginRate != null && apiMarginRate > 0) {
  1383. marginRatioStr = '${apiMarginRate.toStringAsFixed(2)}%';
  1384. } else if (qty > 0 && currentPrice > 0) {
  1385. marginRatioStr =
  1386. '${(principalAmount / (qty * currentPrice) * 100).toStringAsFixed(2)}%';
  1387. } else {
  1388. marginRatioStr = '--';
  1389. }
  1390. // ── 强平价格:按交易对价格精度(coinScale)截断显示,与安卓合约持仓页保持一致
  1391. final pricePrecision = (order['_pricePrecision'] as int?) ?? 2;
  1392. String liquidationPriceStr = '--';
  1393. final blastVal =
  1394. double.tryParse(order['estimatedBlastPrice']?.toString() ?? '');
  1395. if (blastVal != null && blastVal > 0) {
  1396. liquidationPriceStr = _fmtWithScale(blastVal, pricePrecision);
  1397. }
  1398. return Container(
  1399. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1400. padding: const EdgeInsets.all(14),
  1401. decoration: BoxDecoration(
  1402. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  1403. borderRadius: BorderRadius.circular(12),
  1404. border: isDark
  1405. ? null
  1406. : Border.all(color: AppColors.lightBorder, width: 0.5),
  1407. ),
  1408. child: Column(
  1409. crossAxisAlignment: CrossAxisAlignment.start,
  1410. children: [
  1411. // 标题行
  1412. Row(
  1413. children: [
  1414. Text(symbol,
  1415. style: TextStyle(
  1416. color: cs.onSurface,
  1417. fontSize: 15,
  1418. fontWeight: FontWeight.w700)),
  1419. const SizedBox(width: 4),
  1420. Text(l10n.perpetual,
  1421. style: TextStyle(color: cs.onSurface, fontSize: 13)),
  1422. const SizedBox(width: 8),
  1423. _Badge(
  1424. text: isLong ? l10n.openLong : l10n.openShort,
  1425. color: isLong ? AppColors.rise : AppColors.fall),
  1426. const SizedBox(width: 6),
  1427. _Badge(
  1428. text: l10n.crossMargin, color: cs.onSurface.withAlpha(120)),
  1429. const SizedBox(width: 6),
  1430. _Badge(text: '${leverage}X', color: cs.onSurface.withAlpha(120)),
  1431. ],
  1432. ),
  1433. const SizedBox(height: 12),
  1434. // 未实现盈亏(大字)+ 收益率
  1435. Row(
  1436. crossAxisAlignment: CrossAxisAlignment.end,
  1437. children: [
  1438. Expanded(
  1439. child: Column(
  1440. crossAxisAlignment: CrossAxisAlignment.start,
  1441. children: [
  1442. Text(l10n.unrealizedPnlUsdt,
  1443. style: TextStyle(
  1444. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1445. const SizedBox(height: 3),
  1446. Text('${profit >= 0 ? '+' : ''}${fmt(profit)}',
  1447. style: TextStyle(
  1448. color: profitColor,
  1449. fontSize: 22,
  1450. fontWeight: FontWeight.w700)),
  1451. ],
  1452. ),
  1453. ),
  1454. Column(
  1455. crossAxisAlignment: CrossAxisAlignment.end,
  1456. children: [
  1457. Text(l10n.returnRate,
  1458. style: TextStyle(
  1459. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1460. const SizedBox(height: 3),
  1461. Text(profitRateStr,
  1462. style: TextStyle(
  1463. color: profitRateColor,
  1464. fontSize: 14,
  1465. fontWeight: FontWeight.w600)),
  1466. ],
  1467. ),
  1468. ],
  1469. ),
  1470. const SizedBox(height: 12),
  1471. const Divider(height: 1),
  1472. const SizedBox(height: 10),
  1473. Row(
  1474. children: [
  1475. _OrderStat(
  1476. label: l10n.positionSizeWithCoin(baseCoin),
  1477. value: _fmtQty(order['totalPosition'])),
  1478. _OrderStat(
  1479. label: l10n.marginUsdt,
  1480. value: fmt(principalAmount),
  1481. alignCenter: true),
  1482. _OrderStat(
  1483. label: l10n.marginRatio,
  1484. value: marginRatioStr,
  1485. alignEnd: true),
  1486. ],
  1487. ),
  1488. const SizedBox(height: 10),
  1489. Row(
  1490. children: [
  1491. _OrderStat(
  1492. label: l10n.openAvgPriceUsdt, value: fmt(order['openPrice'])),
  1493. _OrderStat(
  1494. label: l10n.currentPriceUsdt,
  1495. value: fmt(order['currentPrice']),
  1496. alignCenter: true),
  1497. _OrderStat(
  1498. label: l10n.liqPriceUsdt,
  1499. value: liquidationPriceStr,
  1500. alignEnd: true),
  1501. ],
  1502. ),
  1503. const SizedBox(height: 10),
  1504. const Divider(height: 1),
  1505. const SizedBox(height: 8),
  1506. Row(
  1507. children: [
  1508. Expanded(
  1509. child: Text(l10n.openTimeWithValue(openTime),
  1510. style: TextStyle(
  1511. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1512. ),
  1513. GestureDetector(
  1514. onTap: () {
  1515. Clipboard.setData(ClipboardData(text: positionId));
  1516. showTopToast(context,
  1517. message: l10n.positionIdCopied,
  1518. backgroundColor: AppColors.rise);
  1519. },
  1520. child: Padding(
  1521. padding:
  1522. const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
  1523. child: Row(
  1524. mainAxisSize: MainAxisSize.min,
  1525. children: [
  1526. Text('${l10n.positionIdPrefix}$positionId',
  1527. style: TextStyle(
  1528. color: cs.onSurface.withAlpha(120),
  1529. fontSize: 11)),
  1530. const SizedBox(width: 4),
  1531. Icon(Icons.content_copy,
  1532. size: 14, color: cs.onSurface.withAlpha(120)),
  1533. ],
  1534. ),
  1535. ),
  1536. ),
  1537. ],
  1538. ),
  1539. ],
  1540. ),
  1541. );
  1542. }
  1543. // ── 历史带单卡片 ────────────────────────────────────────
  1544. Widget _buildHistory(BuildContext context) {
  1545. final cs = Theme.of(context).colorScheme;
  1546. final isDark = Theme.of(context).brightness == Brightness.dark;
  1547. final l10n = AppLocalizations.of(context)!;
  1548. final symbol = order['symbol']?.toString() ?? '--';
  1549. final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol;
  1550. final isLong = (order['direction']?.toString() ?? '0') == '0';
  1551. final leverage = order['leverage']?.toString() ?? '--';
  1552. final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
  1553. final profitColor = profit >= 0 ? AppColors.rise : AppColors.fall;
  1554. final profitRateRaw =
  1555. double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
  1556. final profitRateStr =
  1557. '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
  1558. final profitRateColor =
  1559. profitRateRaw >= 0 ? AppColors.rise : AppColors.fall;
  1560. final openTime = _fmtTimestamp(order['openTime']);
  1561. final closeTime = _fmtTimestamp(order['closeTime']);
  1562. final headerBg = cs.onSurface.withAlpha(22);
  1563. final bodyBg = cs.onSurface.withAlpha(8);
  1564. final dividerColor = cs.outlineVariant.withAlpha(80);
  1565. return Container(
  1566. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1567. decoration: BoxDecoration(
  1568. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  1569. borderRadius: BorderRadius.circular(12),
  1570. ),
  1571. child: ClipRRect(
  1572. borderRadius: BorderRadius.circular(12),
  1573. child: Column(
  1574. crossAxisAlignment: CrossAxisAlignment.start,
  1575. children: [
  1576. // ── 第一部分:深灰色标题行 ─────────────────────────
  1577. Container(
  1578. color: headerBg,
  1579. padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
  1580. child: Row(
  1581. children: [
  1582. Text(symbol,
  1583. style: TextStyle(
  1584. color: cs.onSurface,
  1585. fontSize: 15,
  1586. fontWeight: FontWeight.w700)),
  1587. const SizedBox(width: 4),
  1588. Text(l10n.perpetual,
  1589. style: TextStyle(color: cs.onSurface, fontSize: 13)),
  1590. const SizedBox(width: 8),
  1591. _Badge(
  1592. text: isLong ? l10n.openLong : l10n.openShort,
  1593. color: isLong ? AppColors.rise : AppColors.fall),
  1594. const SizedBox(width: 6),
  1595. _Badge(
  1596. text: l10n.crossMargin,
  1597. color: cs.onSurface.withAlpha(120)),
  1598. const SizedBox(width: 6),
  1599. _Badge(
  1600. text: '${leverage}X', color: cs.onSurface.withAlpha(120)),
  1601. const Spacer(),
  1602. GestureDetector(
  1603. onTap: () => _showShareSheet(context),
  1604. child: Icon(Icons.share_outlined,
  1605. size: 18, color: cs.onSurface.withAlpha(120)),
  1606. ),
  1607. ],
  1608. ),
  1609. ),
  1610. // ── 第二部分:灰色 — 平仓数量 / 已实现盈亏 / 收益率 ──
  1611. Container(
  1612. color: bodyBg,
  1613. padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
  1614. child: Row(
  1615. children: [
  1616. _OrderStat(
  1617. label: l10n.closeSizeWithCoin(baseCoin),
  1618. value: _fmtQty(order['totalPosition'])),
  1619. Expanded(
  1620. child: Column(
  1621. crossAxisAlignment: CrossAxisAlignment.start,
  1622. children: [
  1623. Text(l10n.realizedPnlUsdt,
  1624. style: TextStyle(
  1625. color: cs.onSurface.withAlpha(120),
  1626. fontSize: 11)),
  1627. const SizedBox(height: 2),
  1628. Text(
  1629. '${profit >= 0 ? '+' : ''}${_fmtQty(order['profit'])}',
  1630. style: TextStyle(
  1631. color: profitColor,
  1632. fontSize: 15,
  1633. fontWeight: FontWeight.w700)),
  1634. ],
  1635. ),
  1636. ),
  1637. Column(
  1638. crossAxisAlignment: CrossAxisAlignment.end,
  1639. children: [
  1640. Text(l10n.returnRate,
  1641. style: TextStyle(
  1642. color: cs.onSurface.withAlpha(120),
  1643. fontSize: 11)),
  1644. const SizedBox(height: 2),
  1645. Text(profitRateStr,
  1646. style: TextStyle(
  1647. color: profitRateColor,
  1648. fontSize: 14,
  1649. fontWeight: FontWeight.w600)),
  1650. ],
  1651. ),
  1652. ],
  1653. ),
  1654. ),
  1655. // ── 分割线 ─────────────────────────────────────────
  1656. Divider(height: 0.5, thickness: 0.5, color: dividerColor),
  1657. // ── 第三部分:灰色 — 开仓均价 / 平仓均价 ─────────────
  1658. Container(
  1659. color: bodyBg,
  1660. padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
  1661. child: Row(
  1662. crossAxisAlignment: CrossAxisAlignment.start,
  1663. children: [
  1664. Expanded(
  1665. child: Column(
  1666. crossAxisAlignment: CrossAxisAlignment.start,
  1667. children: [
  1668. Text(l10n.openAvgPriceUsdt,
  1669. style: TextStyle(
  1670. color: cs.onSurface.withAlpha(120),
  1671. fontSize: 11)),
  1672. const SizedBox(height: 2),
  1673. Text(fmt(order['openPrice']),
  1674. style: TextStyle(
  1675. color: cs.onSurface,
  1676. fontSize: 14,
  1677. fontWeight: FontWeight.w600)),
  1678. const SizedBox(height: 4),
  1679. Text(openTime,
  1680. style: TextStyle(
  1681. color: cs.onSurface.withAlpha(100),
  1682. fontSize: 11)),
  1683. ],
  1684. ),
  1685. ),
  1686. Expanded(
  1687. child: Column(
  1688. crossAxisAlignment: CrossAxisAlignment.end,
  1689. children: [
  1690. Text(l10n.closeAvgPriceUsdt,
  1691. style: TextStyle(
  1692. color: cs.onSurface.withAlpha(120),
  1693. fontSize: 11)),
  1694. const SizedBox(height: 2),
  1695. Text(fmt(order['closePrice']),
  1696. style: TextStyle(
  1697. color: cs.onSurface,
  1698. fontSize: 14,
  1699. fontWeight: FontWeight.w600)),
  1700. const SizedBox(height: 4),
  1701. Text(closeTime,
  1702. style: TextStyle(
  1703. color: cs.onSurface.withAlpha(100),
  1704. fontSize: 11)),
  1705. ],
  1706. ),
  1707. ),
  1708. ],
  1709. ),
  1710. ),
  1711. ],
  1712. ),
  1713. ),
  1714. );
  1715. }
  1716. void _showShareSheet(BuildContext context) {
  1717. showModalBottomSheet(
  1718. context: context,
  1719. useRootNavigator: true,
  1720. backgroundColor: Colors.transparent,
  1721. isScrollControlled: true,
  1722. builder: (_) => _ShareOrderSheet(order: order, fmt: fmt),
  1723. );
  1724. }
  1725. }
  1726. // ── 骨架屏 ────────────────────────────────────────────────
  1727. /// 「我的带单」首次加载时的全页骨架(交易员信息卡 + Tab 栏)
  1728. class _MyTradesFullSkeleton extends StatelessWidget {
  1729. const _MyTradesFullSkeleton();
  1730. @override
  1731. Widget build(BuildContext context) {
  1732. final cs = Theme.of(context).colorScheme;
  1733. final isDark = Theme.of(context).brightness == Brightness.dark;
  1734. return Column(
  1735. children: [
  1736. // 交易员信息卡骨架
  1737. AppShimmer(
  1738. child: Container(
  1739. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1740. padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
  1741. child: Column(
  1742. crossAxisAlignment: CrossAxisAlignment.start,
  1743. children: [
  1744. Row(
  1745. crossAxisAlignment: CrossAxisAlignment.start,
  1746. children: [
  1747. shimmerCircle(54),
  1748. const SizedBox(width: 12),
  1749. Expanded(
  1750. child: Column(
  1751. crossAxisAlignment: CrossAxisAlignment.start,
  1752. children: [
  1753. shimmerBox(120, 16),
  1754. const SizedBox(height: 8),
  1755. shimmerBox(200, 13),
  1756. ],
  1757. ),
  1758. ),
  1759. ],
  1760. ),
  1761. const SizedBox(height: 16),
  1762. Container(
  1763. padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
  1764. decoration: BoxDecoration(
  1765. color: cs.onSurface.withAlpha(10),
  1766. borderRadius: BorderRadius.circular(10),
  1767. ),
  1768. child: Column(
  1769. children: [
  1770. Row(
  1771. children: List.generate(
  1772. 3,
  1773. (i) => Expanded(
  1774. child: Padding(
  1775. padding: EdgeInsets.symmetric(
  1776. horizontal: i == 1 ? 8.0 : 0),
  1777. child: Column(
  1778. crossAxisAlignment: i == 0
  1779. ? CrossAxisAlignment.start
  1780. : i == 1
  1781. ? CrossAxisAlignment.center
  1782. : CrossAxisAlignment.end,
  1783. children: [
  1784. shimmerBox(55, 11),
  1785. const SizedBox(height: 5),
  1786. shimmerBox(40, 14),
  1787. ],
  1788. ),
  1789. ),
  1790. ))),
  1791. const SizedBox(height: 12),
  1792. Row(
  1793. children: List.generate(
  1794. 3,
  1795. (i) => Expanded(
  1796. child: Padding(
  1797. padding: EdgeInsets.symmetric(
  1798. horizontal: i == 1 ? 8.0 : 0),
  1799. child: Column(
  1800. crossAxisAlignment: i == 0
  1801. ? CrossAxisAlignment.start
  1802. : i == 1
  1803. ? CrossAxisAlignment.center
  1804. : CrossAxisAlignment.end,
  1805. children: [
  1806. shimmerBox(55, 11),
  1807. const SizedBox(height: 5),
  1808. shimmerBox(40, 14),
  1809. ],
  1810. ),
  1811. ),
  1812. ))),
  1813. ],
  1814. ),
  1815. ),
  1816. ],
  1817. ),
  1818. ),
  1819. ),
  1820. // Tab 栏骨架
  1821. AppShimmer(
  1822. child: Container(
  1823. decoration: BoxDecoration(
  1824. border: Border(
  1825. bottom: BorderSide(
  1826. color: cs.outlineVariant.withAlpha(60), width: 1)),
  1827. ),
  1828. child: Padding(
  1829. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  1830. child: Row(
  1831. children: List.generate(
  1832. 3,
  1833. (i) => Expanded(
  1834. child: Padding(
  1835. padding: EdgeInsets.symmetric(
  1836. horizontal: i == 1 ? 8.0 : 0),
  1837. child: shimmerFill(16, radius: 4),
  1838. ),
  1839. )),
  1840. ),
  1841. ),
  1842. ),
  1843. ),
  1844. // 列表骨架
  1845. Expanded(
  1846. child: ListView.builder(
  1847. padding: const EdgeInsets.only(bottom: 16),
  1848. itemCount: 4,
  1849. itemBuilder: (_, __) => const _FollowerCardSkeleton(),
  1850. ),
  1851. ),
  1852. ],
  1853. );
  1854. }
  1855. }
  1856. /// 跟单用户卡片骨架(对应 _FollowerCard 样式)
  1857. class _FollowerCardSkeleton extends StatelessWidget {
  1858. const _FollowerCardSkeleton();
  1859. @override
  1860. Widget build(BuildContext context) {
  1861. final isDark = Theme.of(context).brightness == Brightness.dark;
  1862. return AppShimmer(
  1863. child: Container(
  1864. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1865. padding: const EdgeInsets.all(14),
  1866. decoration: BoxDecoration(
  1867. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1868. borderRadius: BorderRadius.circular(12),
  1869. ),
  1870. child: Column(
  1871. children: [
  1872. Row(
  1873. children: [
  1874. shimmerCircle(40),
  1875. const SizedBox(width: 10),
  1876. Expanded(child: shimmerBox(100, 14)),
  1877. shimmerBox(55, 32, radius: 20),
  1878. ],
  1879. ),
  1880. const SizedBox(height: 10),
  1881. Row(
  1882. children: List.generate(
  1883. 3,
  1884. (i) => Expanded(
  1885. child: Padding(
  1886. padding: EdgeInsets.symmetric(
  1887. horizontal: i == 1 ? 8.0 : 0),
  1888. child: Column(
  1889. crossAxisAlignment: i == 0
  1890. ? CrossAxisAlignment.start
  1891. : i == 1
  1892. ? CrossAxisAlignment.center
  1893. : CrossAxisAlignment.end,
  1894. children: [
  1895. shimmerBox(50, 11),
  1896. const SizedBox(height: 4),
  1897. shimmerBox(40, 13),
  1898. ],
  1899. ),
  1900. ),
  1901. ))),
  1902. const SizedBox(height: 8),
  1903. Row(children: [
  1904. shimmerBox(16, 12),
  1905. const SizedBox(width: 4),
  1906. shimmerBox(120, 12)
  1907. ]),
  1908. ],
  1909. ),
  1910. ),
  1911. );
  1912. }
  1913. }
  1914. /// 带单仓位卡片骨架(对应 _OrderCard 当前带单样式)
  1915. class _OrderCardSkeleton extends StatelessWidget {
  1916. const _OrderCardSkeleton();
  1917. @override
  1918. Widget build(BuildContext context) {
  1919. final isDark = Theme.of(context).brightness == Brightness.dark;
  1920. return AppShimmer(
  1921. child: Container(
  1922. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1923. padding: const EdgeInsets.all(14),
  1924. decoration: BoxDecoration(
  1925. color:
  1926. isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  1927. borderRadius: BorderRadius.circular(12),
  1928. border: isDark
  1929. ? null
  1930. : Border.all(color: AppColors.lightBorder, width: 0.5),
  1931. ),
  1932. child: Column(
  1933. crossAxisAlignment: CrossAxisAlignment.start,
  1934. children: [
  1935. // 标题行:交易对 + badges
  1936. Row(children: [
  1937. shimmerBox(80, 15),
  1938. const SizedBox(width: 8),
  1939. shimmerBox(40, 20, radius: 4),
  1940. const SizedBox(width: 6),
  1941. shimmerBox(30, 20, radius: 4),
  1942. const SizedBox(width: 6),
  1943. shimmerBox(35, 20, radius: 4),
  1944. ]),
  1945. const SizedBox(height: 12),
  1946. // 未实现盈亏
  1947. Row(
  1948. crossAxisAlignment: CrossAxisAlignment.end,
  1949. children: [
  1950. Expanded(
  1951. child: Column(
  1952. crossAxisAlignment: CrossAxisAlignment.start,
  1953. children: [
  1954. shimmerBox(90, 11),
  1955. const SizedBox(height: 5),
  1956. shimmerBox(120, 22),
  1957. ])),
  1958. Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
  1959. shimmerBox(45, 11),
  1960. const SizedBox(height: 5),
  1961. shimmerBox(70, 14),
  1962. ]),
  1963. ],
  1964. ),
  1965. const SizedBox(height: 12),
  1966. shimmerFill(0.5),
  1967. const SizedBox(height: 10),
  1968. Row(
  1969. children: List.generate(
  1970. 3,
  1971. (i) => Expanded(
  1972. child: Padding(
  1973. padding: EdgeInsets.symmetric(
  1974. horizontal: i == 1 ? 8.0 : 0),
  1975. child: Column(
  1976. crossAxisAlignment: i == 0
  1977. ? CrossAxisAlignment.start
  1978. : i == 1
  1979. ? CrossAxisAlignment.center
  1980. : CrossAxisAlignment.end,
  1981. children: [
  1982. shimmerBox(70, 11),
  1983. const SizedBox(height: 4),
  1984. shimmerBox(50, 13)
  1985. ],
  1986. ),
  1987. ),
  1988. ))),
  1989. const SizedBox(height: 10),
  1990. Row(
  1991. children: List.generate(
  1992. 3,
  1993. (i) => Expanded(
  1994. child: Padding(
  1995. padding: EdgeInsets.symmetric(
  1996. horizontal: i == 1 ? 8.0 : 0),
  1997. child: Column(
  1998. crossAxisAlignment: i == 0
  1999. ? CrossAxisAlignment.start
  2000. : i == 1
  2001. ? CrossAxisAlignment.center
  2002. : CrossAxisAlignment.end,
  2003. children: [
  2004. shimmerBox(70, 11),
  2005. const SizedBox(height: 4),
  2006. shimmerBox(50, 13)
  2007. ],
  2008. ),
  2009. ),
  2010. ))),
  2011. ],
  2012. ),
  2013. ),
  2014. );
  2015. }
  2016. }
  2017. class _Badge extends StatelessWidget {
  2018. const _Badge({required this.text, required this.color});
  2019. final String text;
  2020. final Color color;
  2021. @override
  2022. Widget build(BuildContext context) {
  2023. return Container(
  2024. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  2025. decoration: BoxDecoration(
  2026. color: color.withAlpha(30),
  2027. borderRadius: BorderRadius.circular(4),
  2028. ),
  2029. child: Text(text,
  2030. style: TextStyle(
  2031. color: color, fontSize: 11, fontWeight: FontWeight.w600)),
  2032. );
  2033. }
  2034. }
  2035. class _OrderStat extends StatelessWidget {
  2036. const _OrderStat(
  2037. {required this.label,
  2038. required this.value,
  2039. this.alignEnd = false,
  2040. this.alignCenter = false});
  2041. final String label;
  2042. final String value;
  2043. final bool alignEnd;
  2044. final bool alignCenter;
  2045. @override
  2046. Widget build(BuildContext context) {
  2047. final cs = Theme.of(context).colorScheme;
  2048. final align = alignEnd
  2049. ? CrossAxisAlignment.end
  2050. : alignCenter
  2051. ? CrossAxisAlignment.center
  2052. : CrossAxisAlignment.start;
  2053. return Expanded(
  2054. child: Column(
  2055. crossAxisAlignment: align,
  2056. children: [
  2057. Text(label,
  2058. style:
  2059. TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
  2060. const SizedBox(height: 2),
  2061. Text(value,
  2062. style: TextStyle(
  2063. color: cs.onSurface,
  2064. fontSize: 13,
  2065. fontWeight: FontWeight.w600)),
  2066. ],
  2067. ),
  2068. );
  2069. }
  2070. }
  2071. // ── 分享带单 BottomSheet ───────────────────────────────────
  2072. class _ShareOrderSheet extends ConsumerStatefulWidget {
  2073. const _ShareOrderSheet({required this.order, required this.fmt});
  2074. final Map<String, dynamic> order;
  2075. final String Function(dynamic, {int decimals}) fmt;
  2076. @override
  2077. ConsumerState<_ShareOrderSheet> createState() => _ShareOrderSheetState();
  2078. }
  2079. class _ShareOrderSheetState extends ConsumerState<_ShareOrderSheet> {
  2080. final _cardKey = GlobalKey();
  2081. bool _sharing = false;
  2082. bool _saving = false;
  2083. String? _inviteCode;
  2084. String? _inviteUrl;
  2085. @override
  2086. void initState() {
  2087. super.initState();
  2088. _loadInviteInfo();
  2089. }
  2090. Future<void> _loadInviteInfo() async {
  2091. try {
  2092. final dio = ref.read(dioClientProvider);
  2093. final data = await AuthService(dio).getMyInfo();
  2094. final prefix = data['promotionPrefix']?.toString() ?? '';
  2095. final code = data['promotionCode']?.toString() ?? '';
  2096. final url =
  2097. (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null;
  2098. if (context.mounted) {
  2099. setState(() {
  2100. _inviteCode = code.isNotEmpty ? code : null;
  2101. _inviteUrl = url;
  2102. });
  2103. }
  2104. } catch (_) {}
  2105. }
  2106. String _fmtTimestamp(dynamic ts) {
  2107. if (ts == null) return '--';
  2108. final ms = int.tryParse(ts.toString());
  2109. if (ms == null) return ts.toString();
  2110. final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
  2111. .add(const Duration(hours: 8));
  2112. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  2113. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
  2114. }
  2115. Future<Uint8List?> _renderCard() async {
  2116. final boundary =
  2117. _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
  2118. if (boundary == null) return null;
  2119. final image = await boundary.toImage(pixelRatio: 3.0);
  2120. final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  2121. return byteData?.buffer.asUint8List();
  2122. }
  2123. Future<void> _doSave(BuildContext context) async {
  2124. setState(() => _saving = true);
  2125. try {
  2126. final bytes = await _renderCard();
  2127. if (bytes == null) return;
  2128. await Gal.requestAccess();
  2129. await Gal.putImageBytes(
  2130. bytes,
  2131. name: 'trade_share_${DateTime.now().millisecondsSinceEpoch}',
  2132. );
  2133. if (!context.mounted) return;
  2134. showTopToast(context,
  2135. message: AppLocalizations.of(context)!.saveSuccess,
  2136. backgroundColor: AppColors.rise);
  2137. } on GalException catch (e) {
  2138. if (!context.mounted) return;
  2139. final l10n = AppLocalizations.of(context)!;
  2140. if (e.type == GalExceptionType.accessDenied) {
  2141. showTopToast(context,
  2142. message: l10n.photoPermissionDenied,
  2143. backgroundColor: AppColors.fall);
  2144. } else {
  2145. showTopToast(context,
  2146. message: l10n.saveFailed, backgroundColor: AppColors.fall);
  2147. }
  2148. } catch (e) {
  2149. if (context.mounted) {
  2150. showTopToast(context,
  2151. message: AppLocalizations.of(context)!.saveFailed,
  2152. backgroundColor: AppColors.fall);
  2153. }
  2154. } finally {
  2155. if (context.mounted) setState(() => _saving = false);
  2156. }
  2157. }
  2158. Future<void> _doShare(BuildContext context) async {
  2159. setState(() => _sharing = true);
  2160. try {
  2161. final bytes = await _renderCard();
  2162. if (bytes == null) return;
  2163. final tmpDir = await getTemporaryDirectory();
  2164. final file = File(
  2165. '${tmpDir.path}/trade_share_${DateTime.now().millisecondsSinceEpoch}.png');
  2166. await file.writeAsBytes(bytes);
  2167. if (!context.mounted) return;
  2168. Navigator.of(context).pop();
  2169. await Share.shareXFiles(
  2170. [XFile(file.path, mimeType: 'image/png')],
  2171. subject: AppLocalizations.of(context)!.myTradingProfit,
  2172. );
  2173. } catch (e) {
  2174. if (context.mounted) {
  2175. showTopToast(context,
  2176. message: AppLocalizations.of(context)!.shareFailed,
  2177. backgroundColor: AppColors.fall);
  2178. }
  2179. } finally {
  2180. if (context.mounted) setState(() => _sharing = false);
  2181. }
  2182. }
  2183. @override
  2184. Widget build(BuildContext context) {
  2185. final cs = Theme.of(context).colorScheme;
  2186. final isDark = Theme.of(context).brightness == Brightness.dark;
  2187. final order = widget.order;
  2188. final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
  2189. final pnlPositive = profit >= 0;
  2190. final l10n = AppLocalizations.of(context)!;
  2191. final symbol = order['symbol']?.toString() ?? '--';
  2192. final isLong = (order['direction']?.toString() ?? '0') == '0';
  2193. final leverage = order['leverage']?.toString() ?? '--';
  2194. final profitRateRaw =
  2195. double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
  2196. final profitRateStr =
  2197. '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
  2198. final openPrice = widget.fmt(order['openPrice']);
  2199. final closePrice = widget.fmt(order['closePrice']);
  2200. final openTime = _fmtTimestamp(order['openTime']);
  2201. final closeTime = _fmtTimestamp(order['closeTime']);
  2202. return Container(
  2203. decoration: BoxDecoration(
  2204. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  2205. borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  2206. ),
  2207. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  2208. child: Column(
  2209. mainAxisSize: MainAxisSize.min,
  2210. children: [
  2211. // 拖拽指示条
  2212. Container(
  2213. width: 36,
  2214. height: 4,
  2215. decoration: BoxDecoration(
  2216. color: cs.onSurface.withAlpha(60),
  2217. borderRadius: BorderRadius.circular(2),
  2218. ),
  2219. ),
  2220. const SizedBox(height: 16),
  2221. // 分享卡片预览
  2222. RepaintBoundary(
  2223. key: _cardKey,
  2224. child: _TradeShareCard(
  2225. symbol: symbol,
  2226. isLong: isLong,
  2227. leverage: leverage,
  2228. profitRateStr: profitRateStr,
  2229. pnlPositive: pnlPositive,
  2230. openPrice: openPrice,
  2231. closePrice: closePrice,
  2232. openTime: openTime,
  2233. closeTime: closeTime,
  2234. inviteCode: _inviteCode,
  2235. inviteUrl: _inviteUrl,
  2236. ),
  2237. ),
  2238. const SizedBox(height: 24),
  2239. // 操作按钮行:取消 | 保存海报 | 分享
  2240. Row(
  2241. children: [
  2242. Expanded(
  2243. child: OutlinedButton(
  2244. onPressed: () => Navigator.of(context).pop(),
  2245. style: OutlinedButton.styleFrom(
  2246. padding: const EdgeInsets.symmetric(vertical: 12),
  2247. shape: RoundedRectangleBorder(
  2248. borderRadius: BorderRadius.circular(8)),
  2249. ),
  2250. child: Text(l10n.cancelLabel,
  2251. style: TextStyle(color: cs.onSurface, fontSize: 14)),
  2252. ),
  2253. ),
  2254. const SizedBox(width: 8),
  2255. Expanded(
  2256. child: OutlinedButton(
  2257. onPressed: _saving ? null : () => _doSave(context),
  2258. style: OutlinedButton.styleFrom(
  2259. padding: const EdgeInsets.symmetric(vertical: 12),
  2260. shape: RoundedRectangleBorder(
  2261. borderRadius: BorderRadius.circular(8)),
  2262. ),
  2263. child: _saving
  2264. ? SizedBox(
  2265. width: 16,
  2266. height: 16,
  2267. child: CircularProgressIndicator(
  2268. strokeWidth: 2,
  2269. color: cs.onSurface.withAlpha(153)),
  2270. )
  2271. : Text(l10n.savePoster,
  2272. style: TextStyle(color: cs.onSurface, fontSize: 14)),
  2273. ),
  2274. ),
  2275. const SizedBox(width: 8),
  2276. Expanded(
  2277. child: ElevatedButton(
  2278. onPressed: _sharing ? null : () => _doShare(context),
  2279. style: ElevatedButton.styleFrom(
  2280. backgroundColor:
  2281. pnlPositive ? AppColors.rise : AppColors.fall,
  2282. padding: const EdgeInsets.symmetric(vertical: 12),
  2283. shape: RoundedRectangleBorder(
  2284. borderRadius: BorderRadius.circular(8)),
  2285. elevation: 0,
  2286. ),
  2287. child: _sharing
  2288. ? const SizedBox(
  2289. width: 16,
  2290. height: 16,
  2291. child: CircularProgressIndicator(
  2292. strokeWidth: 2, color: Colors.white),
  2293. )
  2294. : Text(l10n.shareLabel,
  2295. style: const TextStyle(
  2296. color: Colors.white,
  2297. fontSize: 14,
  2298. fontWeight: FontWeight.w600)),
  2299. ),
  2300. ),
  2301. ],
  2302. ),
  2303. ],
  2304. ),
  2305. );
  2306. }
  2307. }
  2308. // ── 带单分享卡片内容 ─────────────────────────────────────────
  2309. class _TradeShareCard extends StatelessWidget {
  2310. const _TradeShareCard({
  2311. required this.symbol,
  2312. required this.isLong,
  2313. required this.leverage,
  2314. required this.profitRateStr,
  2315. required this.pnlPositive,
  2316. required this.openPrice,
  2317. required this.closePrice,
  2318. required this.openTime,
  2319. required this.closeTime,
  2320. this.inviteCode,
  2321. this.inviteUrl,
  2322. });
  2323. final String symbol;
  2324. final bool isLong;
  2325. final String leverage;
  2326. final String profitRateStr;
  2327. final bool pnlPositive;
  2328. final String openPrice;
  2329. final String closePrice;
  2330. final String openTime;
  2331. final String closeTime;
  2332. final String? inviteCode;
  2333. final String? inviteUrl;
  2334. String _baseCoin(String sym) {
  2335. if (sym.contains('/')) return sym.split('/').first;
  2336. return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
  2337. }
  2338. @override
  2339. Widget build(BuildContext context) {
  2340. final isDark = Theme.of(context).brightness == Brightness.dark;
  2341. final l10n = AppLocalizations.of(context)!;
  2342. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  2343. final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall;
  2344. final coinSymbol = _baseCoin(symbol);
  2345. // 主题色变量
  2346. final bgColors = isDark
  2347. ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)]
  2348. : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)];
  2349. final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E);
  2350. final textSecondary = isDark
  2351. ? Colors.white.withAlpha(120)
  2352. : const Color(0xFF1A1F2E).withAlpha(120);
  2353. final textMuted = isDark
  2354. ? Colors.white.withAlpha(80)
  2355. : const Color(0xFF1A1F2E).withAlpha(80);
  2356. final borderColor = isDark
  2357. ? Colors.white.withAlpha(40)
  2358. : const Color(0xFF1A1F2E).withAlpha(30);
  2359. final qrFgColor = isDark ? Colors.white : Colors.black;
  2360. final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white;
  2361. return Container(
  2362. width: double.infinity,
  2363. decoration: BoxDecoration(
  2364. gradient: LinearGradient(
  2365. begin: Alignment.topLeft,
  2366. end: Alignment.bottomRight,
  2367. colors: bgColors,
  2368. ),
  2369. borderRadius: BorderRadius.circular(16),
  2370. ),
  2371. clipBehavior: Clip.antiAlias,
  2372. child: Padding(
  2373. padding: const EdgeInsets.all(20),
  2374. child: Column(
  2375. crossAxisAlignment: CrossAxisAlignment.start,
  2376. children: [
  2377. // LOGO + 品牌名
  2378. Row(
  2379. children: [
  2380. Image.asset(
  2381. 'assets/images/app_icon.png',
  2382. height: 28,
  2383. width: 28,
  2384. errorBuilder: (_, __, ___) => const SizedBox.shrink(),
  2385. ),
  2386. const SizedBox(width: 8),
  2387. Text(
  2388. 'iBit',
  2389. style: TextStyle(
  2390. color: textPrimary,
  2391. fontSize: 14,
  2392. fontWeight: FontWeight.w700,
  2393. letterSpacing: 0.5),
  2394. ),
  2395. ],
  2396. ),
  2397. const SizedBox(height: 14),
  2398. // 币对 + 永续 tag
  2399. Row(
  2400. children: [
  2401. Text(
  2402. '${coinSymbol}USDT',
  2403. style: TextStyle(
  2404. color: textPrimary,
  2405. fontSize: 22,
  2406. fontWeight: FontWeight.w800),
  2407. ),
  2408. const SizedBox(width: 8),
  2409. Container(
  2410. padding:
  2411. const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
  2412. decoration: BoxDecoration(
  2413. color: const Color(0xFFFFAB00),
  2414. borderRadius: BorderRadius.circular(4),
  2415. ),
  2416. child: Text(l10n.perpetual,
  2417. style: const TextStyle(
  2418. color: Colors.white,
  2419. fontSize: 11,
  2420. fontWeight: FontWeight.w700)),
  2421. ),
  2422. ],
  2423. ),
  2424. const SizedBox(height: 4),
  2425. // 方向 + 杠杆
  2426. Text(
  2427. '${isLong ? l10n.openLong : l10n.openShort} ${leverage}X',
  2428. style: TextStyle(
  2429. color: sideColor, fontSize: 15, fontWeight: FontWeight.w700),
  2430. ),
  2431. const SizedBox(height: 14),
  2432. // 收益率(大字)
  2433. Text(l10n.returnRate,
  2434. style: TextStyle(color: textSecondary, fontSize: 12)),
  2435. const SizedBox(height: 4),
  2436. Text(profitRateStr,
  2437. style: TextStyle(
  2438. color: pnlColor,
  2439. fontSize: 36,
  2440. fontWeight: FontWeight.w800,
  2441. letterSpacing: -0.5)),
  2442. const SizedBox(height: 16),
  2443. // 开仓均价 + 平仓均价
  2444. Row(
  2445. children: [
  2446. Expanded(
  2447. child: Column(
  2448. crossAxisAlignment: CrossAxisAlignment.start,
  2449. children: [
  2450. Text(l10n.openAvgPrice,
  2451. style: TextStyle(color: textSecondary, fontSize: 11)),
  2452. const SizedBox(height: 2),
  2453. Text(openPrice,
  2454. style: TextStyle(
  2455. color: textPrimary,
  2456. fontSize: 13,
  2457. fontWeight: FontWeight.w600)),
  2458. ],
  2459. ),
  2460. ),
  2461. Expanded(
  2462. child: Column(
  2463. crossAxisAlignment: CrossAxisAlignment.end,
  2464. children: [
  2465. Text(l10n.avgClosePrice,
  2466. style: TextStyle(color: textSecondary, fontSize: 11)),
  2467. const SizedBox(height: 2),
  2468. Text(closePrice,
  2469. style: TextStyle(
  2470. color: textPrimary,
  2471. fontSize: 13,
  2472. fontWeight: FontWeight.w600)),
  2473. ],
  2474. ),
  2475. ),
  2476. ],
  2477. ),
  2478. const SizedBox(height: 10),
  2479. // 时间
  2480. Text(closeTime != '--' ? closeTime : openTime,
  2481. style: TextStyle(color: textMuted, fontSize: 11)),
  2482. const SizedBox(height: 14),
  2483. // 分隔线
  2484. Divider(color: borderColor, height: 1),
  2485. const SizedBox(height: 14),
  2486. // 邀请码 + 二维码
  2487. Row(
  2488. crossAxisAlignment: CrossAxisAlignment.center,
  2489. children: [
  2490. Expanded(
  2491. child: Column(
  2492. crossAxisAlignment: CrossAxisAlignment.start,
  2493. children: [
  2494. if (inviteCode != null)
  2495. RichText(
  2496. text: TextSpan(
  2497. style: const TextStyle(fontSize: 15),
  2498. children: [
  2499. TextSpan(
  2500. text: l10n.inviteCodeLabel,
  2501. style: TextStyle(color: textSecondary),
  2502. ),
  2503. TextSpan(
  2504. text: inviteCode!,
  2505. style: const TextStyle(
  2506. color: AppColors.brand,
  2507. fontWeight: FontWeight.w700),
  2508. ),
  2509. ],
  2510. ),
  2511. ),
  2512. const SizedBox(height: 4),
  2513. Text(l10n.registerAndEarnRebate,
  2514. style: TextStyle(color: textMuted, fontSize: 12)),
  2515. ],
  2516. ),
  2517. ),
  2518. Container(
  2519. decoration: BoxDecoration(
  2520. border: Border.all(color: borderColor, width: 1),
  2521. borderRadius: BorderRadius.circular(6),
  2522. ),
  2523. padding: const EdgeInsets.all(4),
  2524. child: inviteUrl != null
  2525. ? QrImageView(
  2526. data: inviteUrl!,
  2527. version: QrVersions.auto,
  2528. size: 80,
  2529. eyeStyle: QrEyeStyle(
  2530. eyeShape: QrEyeShape.square,
  2531. color: qrFgColor,
  2532. ),
  2533. dataModuleStyle: QrDataModuleStyle(
  2534. dataModuleShape: QrDataModuleShape.square,
  2535. color: qrFgColor,
  2536. ),
  2537. backgroundColor: qrBgColor,
  2538. errorCorrectionLevel: QrErrorCorrectLevel.M,
  2539. )
  2540. : const SizedBox(width: 80, height: 80),
  2541. ),
  2542. ],
  2543. ),
  2544. ],
  2545. ),
  2546. ),
  2547. );
  2548. }
  2549. }