trader_detail_screen.dart 81 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277
  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/utils/avatar_urls.dart';
  14. import '../../../core/l10n/app_localizations.dart';
  15. import '../../../core/network/dio_client.dart';
  16. import '../../../core/theme/app_colors.dart';
  17. import '../../../core/utils/dialog_utils.dart';
  18. import '../../../core/utils/top_toast.dart';
  19. import '../../../data/repositories/copy_trading_repository.dart';
  20. import '../../../data/models/copy_trading/trader.dart';
  21. import '../../../data/services/auth_service.dart';
  22. import '../../../providers/app_provider.dart';
  23. import '../../../providers/auth_provider.dart';
  24. import '../../../providers/copy_trading_provider.dart';
  25. import '../../../providers/my_copy_trading_provider.dart';
  26. import '../../widgets/common/app_shimmer.dart';
  27. // ── Provider ──────────────────────────────────────────────────────────────────
  28. final _traderDetailProvider =
  29. FutureProvider.autoDispose.family<Map<String, dynamic>?, String>(
  30. (ref, traderId) =>
  31. ref.read(copyTradingRepositoryProvider).getTraderInfo(traderId),
  32. );
  33. // ── Screen ────────────────────────────────────────────────────────────────────
  34. class TraderDetailScreen extends ConsumerStatefulWidget {
  35. const TraderDetailScreen({super.key, required this.traderId});
  36. final String traderId;
  37. @override
  38. ConsumerState<TraderDetailScreen> createState() => _TraderDetailScreenState();
  39. }
  40. class _TraderDetailScreenState extends ConsumerState<TraderDetailScreen>
  41. with SingleTickerProviderStateMixin {
  42. // ── Tab: 历史带单=0 / 当前带单=1
  43. late TabController _tabController;
  44. final _scrollCtrl = ScrollController();
  45. // ── Favorite / Follow state
  46. bool _isFavorite = false;
  47. bool _isFollowing = false;
  48. bool _favoriteLoading = false;
  49. bool _followLoading = false;
  50. bool _initialized = false;
  51. // ── 带单合约
  52. bool _symbolExpanded = true;
  53. List<String> _traderSymbols = [];
  54. // ── 当前带单
  55. List<Map<String, dynamic>> _currentOrders = [];
  56. bool _loadingCurrent = false;
  57. bool _currentLoaded = false;
  58. int _currentPage = 1;
  59. bool _currentHasMore = true;
  60. bool _currentLoadingMore = false;
  61. // ── 历史带单
  62. List<Map<String, dynamic>> _historyOrders = [];
  63. bool _loadingHistory = false;
  64. bool _historyLoaded = false;
  65. int _historyPage = 1;
  66. bool _historyHasMore = true;
  67. bool _historyLoadingMore = false;
  68. static const _pageSize = 10;
  69. static const _avatarColors = [
  70. Color(0xFFf7931a),
  71. Color(0xFF627eea),
  72. Color(0xFF9945ff),
  73. Color(0xFFf3ba2f),
  74. Color(0xFF2775ca),
  75. Color(0xFF00aae4),
  76. ];
  77. @override
  78. void initState() {
  79. super.initState();
  80. // 默认显示「当前带单」(index 0)
  81. _tabController = TabController(length: 2, vsync: this, initialIndex: 0);
  82. _tabController.addListener(() {
  83. if (!mounted) return;
  84. if (!_tabController.indexIsChanging) {
  85. // 切换到历史带单 tab 时重新拉取
  86. if (_tabController.index == 1) _loadHistory();
  87. setState(() {}); // 仅更新 tab 标题计数
  88. }
  89. });
  90. // 进入页面同时预加载两个 tab 的数据
  91. _loadCurrent();
  92. _loadHistory();
  93. _loadTraderSymbols();
  94. }
  95. @override
  96. void dispose() {
  97. _tabController.dispose();
  98. _scrollCtrl.dispose();
  99. super.dispose();
  100. }
  101. Future<void> _loadTraderSymbols() async {
  102. try {
  103. final list = await ref
  104. .read(copyTradingRepositoryProvider)
  105. .getTraderSymbols(widget.traderId);
  106. if (mounted) {
  107. setState(() {
  108. _traderSymbols = list
  109. .map((s) =>
  110. s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '')
  111. .where((n) => n.isNotEmpty)
  112. .toList()
  113. ..sort();
  114. });
  115. }
  116. } catch (_) {}
  117. }
  118. Color _avatarBg(String name) => _avatarColors[
  119. name.isEmpty ? 0 : name.codeUnitAt(0) % _avatarColors.length];
  120. String _fmt(dynamic raw, {int decimals = 2}) {
  121. if (raw == null) return '--';
  122. final d = double.tryParse(raw.toString());
  123. if (d == null) return '--';
  124. return d.toStringAsFixed(decimals);
  125. }
  126. String _fmtPercent(dynamic raw) {
  127. if (raw == null) return '--';
  128. final d = double.tryParse(raw.toString());
  129. if (d == null) return '--';
  130. return '${d >= 0 ? '+' : ''}${d.toStringAsFixed(2)}%';
  131. }
  132. Color _pnlColor(dynamic raw) {
  133. final d = double.tryParse(raw?.toString() ?? '');
  134. if (d == null) return AppColors.darkTextSecondary;
  135. return d >= 0 ? AppColors.rise : AppColors.fall;
  136. }
  137. // ── Data loading ──────────────────────────────────────────────────────────
  138. Future<void> _loadCurrent() async {
  139. if (_loadingCurrent) return;
  140. if (!mounted) return;
  141. setState(() {
  142. _loadingCurrent = true;
  143. _currentPage = 1;
  144. _currentHasMore = true;
  145. });
  146. try {
  147. final orders =
  148. await ref.read(copyTradingRepositoryProvider).getTraderOrders(
  149. traderId: widget.traderId,
  150. type: 'current',
  151. page: 1,
  152. pageSize: _pageSize,
  153. );
  154. if (mounted)
  155. setState(() {
  156. _currentOrders = orders;
  157. _currentLoaded = true;
  158. _currentHasMore = orders.length >= _pageSize;
  159. });
  160. } catch (_) {
  161. if (mounted) setState(() => _currentLoaded = true);
  162. } finally {
  163. if (mounted) setState(() => _loadingCurrent = false);
  164. }
  165. }
  166. Future<void> _loadMoreCurrent() async {
  167. if (!_currentHasMore || _currentLoadingMore || _loadingCurrent) return;
  168. final nextPage = _currentPage + 1;
  169. setState(() => _currentLoadingMore = true);
  170. try {
  171. final orders =
  172. await ref.read(copyTradingRepositoryProvider).getTraderOrders(
  173. traderId: widget.traderId,
  174. type: 'current',
  175. page: nextPage,
  176. pageSize: _pageSize,
  177. );
  178. if (mounted)
  179. setState(() {
  180. _currentOrders = [..._currentOrders, ...orders];
  181. _currentPage = nextPage;
  182. _currentHasMore = orders.length >= _pageSize;
  183. _currentLoadingMore = false;
  184. });
  185. } catch (_) {
  186. if (mounted) setState(() => _currentLoadingMore = false);
  187. }
  188. }
  189. Future<void> _loadHistory() async {
  190. if (_loadingHistory) return;
  191. setState(() {
  192. _loadingHistory = true;
  193. _historyPage = 1;
  194. _historyHasMore = true;
  195. });
  196. try {
  197. final orders =
  198. await ref.read(copyTradingRepositoryProvider).getTraderOrders(
  199. traderId: widget.traderId,
  200. type: 'history',
  201. page: 1,
  202. pageSize: _pageSize,
  203. );
  204. if (mounted)
  205. setState(() {
  206. _historyOrders = orders;
  207. _historyLoaded = true;
  208. _historyHasMore = orders.length >= _pageSize;
  209. });
  210. } catch (_) {
  211. if (mounted) setState(() => _historyLoaded = true);
  212. } finally {
  213. if (mounted) setState(() => _loadingHistory = false);
  214. }
  215. }
  216. Future<void> _loadMoreHistory() async {
  217. if (!_historyHasMore || _historyLoadingMore || _loadingHistory) return;
  218. final nextPage = _historyPage + 1;
  219. setState(() => _historyLoadingMore = true);
  220. try {
  221. final orders =
  222. await ref.read(copyTradingRepositoryProvider).getTraderOrders(
  223. traderId: widget.traderId,
  224. type: 'history',
  225. page: nextPage,
  226. pageSize: _pageSize,
  227. );
  228. if (mounted)
  229. setState(() {
  230. _historyOrders = [..._historyOrders, ...orders];
  231. _historyPage = nextPage;
  232. _historyHasMore = orders.length >= _pageSize;
  233. _historyLoadingMore = false;
  234. });
  235. } catch (_) {
  236. if (mounted) setState(() => _historyLoadingMore = false);
  237. }
  238. }
  239. // ── Build ─────────────────────────────────────────────────────────────────
  240. @override
  241. Widget build(BuildContext context) {
  242. ref.watch(localeProvider);
  243. final cs = Theme.of(context).colorScheme;
  244. final isDark = Theme.of(context).brightness == Brightness.dark;
  245. final async = ref.watch(_traderDetailProvider(widget.traderId));
  246. ref.listen(_traderDetailProvider(widget.traderId), (_, next) {
  247. next.whenData((trader) {
  248. if (trader == null || _initialized) return;
  249. setState(() {
  250. _isFavorite = Trader.parseFavoriteFlag(
  251. trader['isFavorite'] ?? trader['isFavorited'] ?? trader['favorite'],
  252. );
  253. _isFollowing = trader['isFollow']?.toString() == '1' ||
  254. trader['follow']?.toString() == '1';
  255. _initialized = true;
  256. });
  257. });
  258. });
  259. final isTrader = ref.watch(copyTradingProvider.select((s) => s.isTrader));
  260. final trader = async.valueOrNull;
  261. return Scaffold(
  262. backgroundColor: isDark ? AppColors.darkBg : AppColors.lightBgSecondary,
  263. appBar: AppBar(
  264. backgroundColor: isDark ? AppColors.darkBg : AppColors.lightBg,
  265. title: Text(AppLocalizations.of(context)!.traderDetail,
  266. style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
  267. actions: [
  268. // 带单员不显示关注按钮
  269. if (!isTrader)
  270. async.whenOrNull(
  271. data: (td) => td == null
  272. ? const SizedBox()
  273. : GestureDetector(
  274. onTap: () => _toggleFavorite(td),
  275. child: Padding(
  276. padding: const EdgeInsets.all(14),
  277. child: Icon(
  278. _isFavorite
  279. ? Icons.favorite
  280. : Icons.favorite_border,
  281. color: _isFavorite
  282. ? Colors.red
  283. : cs.onSurface.withAlpha(153),
  284. size: 24,
  285. ),
  286. ),
  287. ),
  288. ) ??
  289. const SizedBox(),
  290. ],
  291. ),
  292. body: Column(
  293. children: [
  294. Expanded(
  295. child: _buildContent(context, cs, isDark, async, trader, isTrader),
  296. ),
  297. // 带单员不显示跟单/取消按钮
  298. if (!isTrader && trader != null)
  299. _BottomButton(
  300. isFollowing: _isFollowing,
  301. isFull: _isFull(trader),
  302. loading: _followLoading,
  303. onTap: () => _onFollowTap(context, trader),
  304. ),
  305. ],
  306. ),
  307. );
  308. }
  309. bool _isFull(Map<String, dynamic> trader) {
  310. final following = int.tryParse(trader['following']?.toString() ?? '') ?? 0;
  311. final maxFollow = int.tryParse(trader['maxFollow']?.toString() ?? '') ?? 0;
  312. return !_isFollowing && maxFollow > 0 && following >= maxFollow;
  313. }
  314. Widget _buildContent(
  315. BuildContext context,
  316. ColorScheme cs,
  317. bool isDark,
  318. AsyncValue<Map<String, dynamic>?> async,
  319. Map<String, dynamic>? trader,
  320. bool isTrader,
  321. ) {
  322. if (async.hasError && trader == null) {
  323. return Center(
  324. child: Column(
  325. mainAxisSize: MainAxisSize.min,
  326. children: [
  327. Text(AppLocalizations.of(context)!.loadFailed,
  328. style: TextStyle(color: cs.onSurface.withAlpha(153))),
  329. const SizedBox(height: 12),
  330. ElevatedButton(
  331. onPressed: () =>
  332. ref.invalidate(_traderDetailProvider(widget.traderId)),
  333. style: ElevatedButton.styleFrom(
  334. backgroundColor: AppColors.brand,
  335. foregroundColor: Colors.black),
  336. child: Text(AppLocalizations.of(context)!.retry),
  337. ),
  338. ],
  339. ),
  340. );
  341. }
  342. if (async.isLoading && trader == null) {
  343. return const _TraderDetailSkeleton();
  344. }
  345. final l10n = AppLocalizations.of(context)!;
  346. final tabBar = TabBar(
  347. controller: _tabController,
  348. labelColor: cs.onSurface,
  349. unselectedLabelColor: cs.onSurface.withAlpha(153),
  350. indicatorColor: AppColors.brand,
  351. indicatorWeight: 2.5,
  352. dividerColor: Colors.transparent,
  353. labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
  354. unselectedLabelStyle: const TextStyle(fontSize: 14),
  355. tabs: [
  356. Tab(
  357. text:
  358. '${l10n.currentCopyOrders}(${_currentOrders.length})'),
  359. Tab(text: l10n.historyCopyOrders),
  360. ],
  361. );
  362. // NestedScrollView: 外层滚动(header)与内层滚动(各 tab 列表)完全独立,
  363. // 切换 tab 不会影响外层滚动位置,彻底解决回顶问题。
  364. return NestedScrollView(
  365. controller: _scrollCtrl,
  366. headerSliverBuilder: (ctx, innerBoxIsScrolled) => [
  367. SliverToBoxAdapter(child: _buildProfile(trader, cs, isDark)),
  368. SliverToBoxAdapter(child: _buildAccountInfo(trader, cs, isDark)),
  369. SliverToBoxAdapter(child: _buildCoreData(trader, cs, isDark)),
  370. SliverToBoxAdapter(child: _buildSymbolSection(cs, isDark, l10n)),
  371. SliverOverlapAbsorber(
  372. handle: NestedScrollView.sliverOverlapAbsorberHandleFor(ctx),
  373. sliver: SliverPersistentHeader(
  374. pinned: true,
  375. delegate: _StickyTabBarDelegate(
  376. tabBar: tabBar,
  377. bgColor: isDark ? AppColors.darkBg : AppColors.lightBg,
  378. dividerColor: cs.outlineVariant.withAlpha(80),
  379. ),
  380. ),
  381. ),
  382. ],
  383. body: TabBarView(
  384. controller: _tabController,
  385. physics: const NeverScrollableScrollPhysics(),
  386. children: [
  387. _OrderPage(
  388. orders: _currentOrders,
  389. loading: _loadingCurrent,
  390. loaded: _currentLoaded,
  391. hasMore: _currentHasMore,
  392. loadingMore: _currentLoadingMore,
  393. isHistory: false,
  394. cs: cs,
  395. onRefresh: () async {
  396. ref.invalidate(_traderDetailProvider(widget.traderId));
  397. await _loadCurrent();
  398. },
  399. onLoadMore: _loadMoreCurrent,
  400. ),
  401. _OrderPage(
  402. orders: _historyOrders,
  403. loading: _loadingHistory,
  404. loaded: _historyLoaded,
  405. hasMore: _historyHasMore,
  406. loadingMore: _historyLoadingMore,
  407. isHistory: true,
  408. cs: cs,
  409. onRefresh: () async {
  410. ref.invalidate(_traderDetailProvider(widget.traderId));
  411. await _loadHistory();
  412. },
  413. onLoadMore: _loadMoreHistory,
  414. ),
  415. ],
  416. ),
  417. );
  418. }
  419. // ── Profile ───────────────────────────────────────────────────────────────
  420. Widget _buildProfile(
  421. Map<String, dynamic>? trader, ColorScheme cs, bool isDark) {
  422. final nickname = trader?['nickname']?.toString() ?? '';
  423. final description = trader?['description']?.toString() ?? '';
  424. final tags = (trader?['tags'] as List?)
  425. ?.map((e) => e.toString())
  426. .where((t) => t.isNotEmpty)
  427. .toList() ??
  428. [];
  429. final following = trader?['following']?.toString() ?? '--';
  430. final maxFollow = trader?['maxFollow']?.toString() ?? '--';
  431. final registerDays = trader?['registerDays']?.toString() ?? '--';
  432. final levelName = trader?['levelName']?.toString() ?? '';
  433. final avatarUrl = trader != null
  434. ? resolvedAvatarUrlFromRecord(Map<String, dynamic>.from(trader))
  435. : null;
  436. final letter = nickname.isNotEmpty ? nickname[0].toUpperCase() : '?';
  437. return Container(
  438. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  439. padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
  440. child: Column(
  441. crossAxisAlignment: CrossAxisAlignment.start,
  442. children: [
  443. Row(
  444. crossAxisAlignment: CrossAxisAlignment.center,
  445. children: [
  446. _Avatar(
  447. letter: letter,
  448. avatarUrl: avatarUrl,
  449. bg: _avatarBg(nickname)),
  450. const SizedBox(width: 14),
  451. Expanded(
  452. child: Column(
  453. crossAxisAlignment: CrossAxisAlignment.start,
  454. children: [
  455. // 昵称 + 等级 badge
  456. Row(
  457. children: [
  458. Flexible(
  459. child: Text(nickname,
  460. style: TextStyle(
  461. color: cs.onSurface,
  462. fontSize: 18,
  463. fontWeight: FontWeight.w700)),
  464. ),
  465. if (levelName.isNotEmpty) ...[
  466. const SizedBox(width: 8),
  467. Container(
  468. padding: const EdgeInsets.symmetric(
  469. horizontal: 8, vertical: 2),
  470. decoration: BoxDecoration(
  471. color: AppColors.brand,
  472. borderRadius: BorderRadius.circular(4),
  473. ),
  474. child: Text(levelName,
  475. style: const TextStyle(
  476. color: Colors.black,
  477. fontSize: 11,
  478. fontWeight: FontWeight.w700)),
  479. ),
  480. ],
  481. ],
  482. ),
  483. const SizedBox(height: 8),
  484. // 入驻天数 + 当前跟随
  485. Row(
  486. children: [
  487. Icon(Icons.calendar_today_outlined,
  488. size: 13, color: cs.onSurface.withAlpha(120)),
  489. const SizedBox(width: 3),
  490. Text(
  491. AppLocalizations.of(context)!
  492. .settledDaysLabelFmt(registerDays),
  493. style: TextStyle(
  494. color: cs.onSurface.withAlpha(153),
  495. fontSize: 12)),
  496. const SizedBox(width: 16),
  497. Icon(Icons.group_outlined,
  498. size: 13, color: cs.onSurface.withAlpha(120)),
  499. const SizedBox(width: 3),
  500. Text(
  501. AppLocalizations.of(context)!
  502. .currentFollowingLabelFmt(following, maxFollow),
  503. style: TextStyle(
  504. color: cs.onSurface.withAlpha(153),
  505. fontSize: 12)),
  506. ],
  507. ),
  508. ],
  509. ),
  510. ),
  511. ],
  512. ),
  513. if (description.isNotEmpty) ...[
  514. const SizedBox(height: 12),
  515. Text(description,
  516. style: TextStyle(
  517. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  518. ],
  519. if (tags.isNotEmpty) ...[
  520. const SizedBox(height: 10),
  521. Wrap(
  522. spacing: 6,
  523. runSpacing: 6,
  524. children: tags.map((t) => _TagChip(tag: t)).toList(),
  525. ),
  526. ],
  527. ],
  528. ),
  529. );
  530. }
  531. // ── 账户信息 ──────────────────────────────────────────────────────────────
  532. Widget _buildAccountInfo(
  533. Map<String, dynamic>? trader, ColorScheme cs, bool isDark) {
  534. final l10n = AppLocalizations.of(context)!;
  535. return _StatCard(
  536. isDark: isDark,
  537. title: l10n.accountInfoTitle,
  538. rows: [
  539. [
  540. _DetailStat(
  541. label: l10n.cumFollowProfitAmtUsdt,
  542. value: _fmt(trader?['profitAmount']),
  543. valueColor: AppColors.rise,
  544. ),
  545. _DetailStat(
  546. label: l10n.fundStrengthUsdt,
  547. value: trader?['moneyStrength']?.toString() ?? '--',
  548. ),
  549. ],
  550. [
  551. _DetailStat(
  552. label: l10n.cumFollowerCount,
  553. value: trader?['followCustomer']?.toString() ?? '--',
  554. ),
  555. _DetailStat(
  556. label: l10n.cumTradingDays,
  557. value: trader?['tradingDays']?.toString() ?? '--',
  558. ),
  559. ],
  560. ],
  561. );
  562. }
  563. // ── 核心数据 ──────────────────────────────────────────────────────────────
  564. Widget _buildCoreData(
  565. Map<String, dynamic>? trader, ColorScheme cs, bool isDark) {
  566. final dayYield30 = trader?['dayYield14'];
  567. final profit30d = trader?['teamProfit14'];
  568. final winRate = trader?['winRate14'] ?? trader?['winRate30'];
  569. final dividendPercent = trader?['dividendPercent'];
  570. String profit30dStr;
  571. Color? profit30dColor;
  572. if (profit30d != null) {
  573. final d = double.tryParse(profit30d.toString());
  574. profit30dStr =
  575. d == null ? '--' : '${d >= 0 ? '+' : ''}${d.toStringAsFixed(2)}';
  576. profit30dColor = _pnlColor(profit30d);
  577. } else {
  578. profit30dStr = '--';
  579. profit30dColor = null;
  580. }
  581. final l10n = AppLocalizations.of(context)!;
  582. return _StatCard(
  583. isDark: isDark,
  584. title: l10n.coreDataTitle,
  585. rows: [
  586. [
  587. _DetailStat(
  588. label: l10n.yield14d,
  589. value: _fmtPercent(dayYield30),
  590. valueColor: _pnlColor(dayYield30),
  591. ),
  592. _DetailStat(
  593. label: l10n.profit14dUsdt,
  594. value: profit30dStr,
  595. valueColor: profit30dColor,
  596. ),
  597. ],
  598. [
  599. _DetailStat(
  600. label: l10n.winRate14d,
  601. value: winRate == null ? '--' : '${_fmt(winRate, decimals: 1)}%',
  602. ),
  603. _DetailStat(
  604. label: l10n.profitShareRatio,
  605. value: dividendPercent == null
  606. ? '--'
  607. : '${_fmt(dividendPercent, decimals: 0)}%',
  608. ),
  609. ],
  610. ],
  611. );
  612. }
  613. // ── 带单合约 ──────────────────────────────────────────────────────────────
  614. Widget _buildSymbolSection(ColorScheme cs, bool isDark, AppLocalizations l10n) {
  615. final symbols = _traderSymbols;
  616. if (symbols.isEmpty) return const SizedBox.shrink();
  617. return Container(
  618. margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
  619. padding: const EdgeInsets.all(16),
  620. decoration: BoxDecoration(
  621. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  622. borderRadius: BorderRadius.circular(12),
  623. ),
  624. child: Column(
  625. crossAxisAlignment: CrossAxisAlignment.start,
  626. children: [
  627. GestureDetector(
  628. onTap: () => setState(() => _symbolExpanded = !_symbolExpanded),
  629. behavior: HitTestBehavior.opaque,
  630. child: Row(
  631. children: [
  632. Text(l10n.tradingContracts,
  633. style: TextStyle(
  634. color: cs.onSurface,
  635. fontSize: 14,
  636. fontWeight: FontWeight.w700)),
  637. const Spacer(),
  638. Icon(
  639. _symbolExpanded
  640. ? Icons.keyboard_arrow_up
  641. : Icons.keyboard_arrow_down,
  642. color: cs.onSurface.withAlpha(153),
  643. size: 20,
  644. ),
  645. ],
  646. ),
  647. ),
  648. if (_symbolExpanded) ...[
  649. const SizedBox(height: 10),
  650. Wrap(
  651. spacing: 8,
  652. runSpacing: 8,
  653. children: symbols.map((sym) {
  654. return Container(
  655. padding:
  656. const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
  657. decoration: BoxDecoration(
  658. color: AppColors.brand.withValues(alpha: 0.12),
  659. borderRadius: BorderRadius.circular(8),
  660. border: Border.all(
  661. color: AppColors.brand.withValues(alpha: 0.6),
  662. width: 1.5,
  663. ),
  664. ),
  665. child: Text(
  666. sym,
  667. style: const TextStyle(
  668. color: AppColors.brand,
  669. fontSize: 13,
  670. fontWeight: FontWeight.w600,
  671. ),
  672. ),
  673. );
  674. }).toList(),
  675. ),
  676. ],
  677. ],
  678. ),
  679. );
  680. }
  681. // ── Actions ───────────────────────────────────────────────────────────────
  682. void _onFollowTap(BuildContext context, Map<String, dynamic> trader) {
  683. if (_isFollowing) {
  684. _toggleFollow(trader);
  685. } else {
  686. context.push<bool>('/follow-setting', extra: trader).then((result) {
  687. if (result == true && mounted) {
  688. setState(() => _isFollowing = true);
  689. ref.read(myCopyTradingProvider.notifier).silentRefresh();
  690. ref.read(copyTradingProvider.notifier).silentRefresh();
  691. }
  692. });
  693. }
  694. }
  695. Future<void> _toggleFavorite(Map<String, dynamic> trader) async {
  696. if (_favoriteLoading) return;
  697. if (!ref.read(isLoggedInProvider)) {
  698. context.push('/login');
  699. return;
  700. }
  701. setState(() => _favoriteLoading = true);
  702. final repo = ref.read(copyTradingRepositoryProvider);
  703. final id = trader['id']?.toString() ?? widget.traderId;
  704. try {
  705. final ok = _isFavorite
  706. ? await repo.unfavoriteTrader(id)
  707. : await repo.favoriteTrader(id);
  708. if (mounted) {
  709. setState(() {
  710. if (ok) _isFavorite = !_isFavorite;
  711. _favoriteLoading = false;
  712. });
  713. if (!ok)
  714. showTipDialog(context,
  715. content: AppLocalizations.of(context)!.operationFailedRetry);
  716. }
  717. } catch (e) {
  718. if (mounted) {
  719. setState(() => _favoriteLoading = false);
  720. showTipDialog(context, content: extractErrorMessage(e));
  721. }
  722. }
  723. }
  724. Future<void> _toggleFollow(Map<String, dynamic> trader) async {
  725. if (_followLoading) return;
  726. if (!ref.read(isLoggedInProvider)) {
  727. context.push('/login');
  728. return;
  729. }
  730. final id = trader['id']?.toString() ?? widget.traderId;
  731. if (_isFollowing) {
  732. final confirmed = await showConfirmDialog(context,
  733. content: AppLocalizations.of(context)!.unfollowTraderConfirm);
  734. if (!confirmed || !mounted) return;
  735. }
  736. setState(() => _followLoading = true);
  737. final repo = ref.read(copyTradingRepositoryProvider);
  738. try {
  739. bool ok;
  740. if (_isFollowing) {
  741. ok = await repo.unfollowTrader(id);
  742. if (ok && mounted) {
  743. setState(() => _isFollowing = false);
  744. ref.read(myCopyTradingProvider.notifier).silentRefresh();
  745. ref.read(copyTradingProvider.notifier).silentRefresh();
  746. }
  747. } else {
  748. ok = await repo.followTrader({'traderId': id});
  749. if (ok && mounted) setState(() => _isFollowing = true);
  750. }
  751. if (mounted) {
  752. setState(() => _followLoading = false);
  753. if (!ok)
  754. showTipDialog(context,
  755. content: AppLocalizations.of(context)!.operationFailedRetry);
  756. }
  757. } catch (e) {
  758. if (mounted) {
  759. setState(() => _followLoading = false);
  760. showTipDialog(context, content: extractErrorMessage(e));
  761. }
  762. }
  763. }
  764. }
  765. // ── 订单列表页(每个 tab 独立滚动,切换不影响外层位置)─────────────────────────────
  766. class _OrderPage extends StatelessWidget {
  767. const _OrderPage({
  768. required this.orders,
  769. required this.loading,
  770. required this.loaded,
  771. required this.hasMore,
  772. required this.loadingMore,
  773. required this.isHistory,
  774. required this.cs,
  775. required this.onRefresh,
  776. required this.onLoadMore,
  777. });
  778. final List<Map<String, dynamic>> orders;
  779. final bool loading;
  780. final bool loaded;
  781. final bool hasMore;
  782. final bool loadingMore;
  783. final bool isHistory;
  784. final ColorScheme cs;
  785. final Future<void> Function() onRefresh;
  786. final VoidCallback onLoadMore;
  787. @override
  788. Widget build(BuildContext context) {
  789. return Builder(
  790. builder: (ctx) {
  791. return NotificationListener<ScrollNotification>(
  792. onNotification: (n) {
  793. if (n is ScrollEndNotification &&
  794. n.metrics.pixels >= n.metrics.maxScrollExtent - 300) {
  795. onLoadMore();
  796. }
  797. return false;
  798. },
  799. child: RefreshIndicator(
  800. color: AppColors.brand,
  801. onRefresh: onRefresh,
  802. child: CustomScrollView(
  803. slivers: [
  804. SliverOverlapInjector(
  805. handle: NestedScrollView.sliverOverlapAbsorberHandleFor(ctx),
  806. ),
  807. if (loading && !loaded)
  808. const SliverFillRemaining(
  809. child: Center(
  810. child: CircularProgressIndicator(color: AppColors.brand),
  811. ),
  812. )
  813. else if (loaded && orders.isEmpty)
  814. SliverFillRemaining(
  815. child: Center(
  816. child: Text(AppLocalizations.of(context)!.noTradeRecords,
  817. style: TextStyle(
  818. color: cs.onSurface.withAlpha(153),
  819. fontSize: 14)),
  820. ),
  821. )
  822. else
  823. SliverPadding(
  824. padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
  825. sliver: SliverList(
  826. delegate: SliverChildBuilderDelegate(
  827. (_, i) {
  828. if (i < orders.length) {
  829. return isHistory
  830. ? _HistoryOrderCard(order: orders[i])
  831. : _CurrentOrderCard(order: orders[i]);
  832. }
  833. if (loadingMore) {
  834. return const Padding(
  835. padding: EdgeInsets.symmetric(vertical: 16),
  836. child: Center(
  837. child: CircularProgressIndicator(
  838. color: AppColors.brand, strokeWidth: 2),
  839. ),
  840. );
  841. }
  842. if (!hasMore && orders.isNotEmpty) {
  843. return Padding(
  844. padding: const EdgeInsets.symmetric(vertical: 16),
  845. child: Center(
  846. child: Text(
  847. AppLocalizations.of(context)!.noMore,
  848. style: TextStyle(
  849. color: cs.onSurface.withAlpha(100),
  850. fontSize: 12)),
  851. ),
  852. );
  853. }
  854. return const SizedBox(height: 20);
  855. },
  856. childCount: orders.length + 1,
  857. ),
  858. ),
  859. ),
  860. ],
  861. ),
  862. ),
  863. );
  864. },
  865. );
  866. }
  867. }
  868. // ── Sticky TabBar delegate ────────────────────────────────────────────────────
  869. class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  870. const _StickyTabBarDelegate({
  871. required this.tabBar,
  872. required this.bgColor,
  873. required this.dividerColor,
  874. });
  875. final TabBar tabBar;
  876. final Color bgColor;
  877. final Color dividerColor;
  878. @override
  879. double get minExtent => tabBar.preferredSize.height;
  880. @override
  881. double get maxExtent => tabBar.preferredSize.height;
  882. @override
  883. Widget build(
  884. BuildContext context, double shrinkOffset, bool overlapsContent) {
  885. return DecoratedBox(
  886. decoration: BoxDecoration(
  887. color: bgColor,
  888. border: Border(bottom: BorderSide(color: dividerColor, width: 0.5)),
  889. ),
  890. child: tabBar,
  891. );
  892. }
  893. @override
  894. bool shouldRebuild(covariant _StickyTabBarDelegate old) =>
  895. old.tabBar != tabBar || old.bgColor != bgColor;
  896. }
  897. // ── 统计卡片 ──────────────────────────────────────────────────────────────────
  898. class _StatCard extends StatelessWidget {
  899. const _StatCard({
  900. required this.isDark,
  901. required this.title,
  902. required this.rows,
  903. });
  904. final bool isDark;
  905. final String title;
  906. final List<List<Widget>> rows;
  907. @override
  908. Widget build(BuildContext context) {
  909. final cs = Theme.of(context).colorScheme;
  910. return Container(
  911. margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
  912. padding: const EdgeInsets.all(16),
  913. decoration: BoxDecoration(
  914. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  915. borderRadius: BorderRadius.circular(12),
  916. ),
  917. child: Column(
  918. crossAxisAlignment: CrossAxisAlignment.start,
  919. children: [
  920. Text(title,
  921. style: TextStyle(
  922. color: cs.onSurface,
  923. fontSize: 14,
  924. fontWeight: FontWeight.w700)),
  925. ...rows.expand((row) => [
  926. const SizedBox(height: 14),
  927. Row(children: row),
  928. ]),
  929. ],
  930. ),
  931. );
  932. }
  933. }
  934. // ── Avatar ────────────────────────────────────────────────────────────────────
  935. class _Avatar extends StatelessWidget {
  936. const _Avatar({required this.letter, required this.bg, this.avatarUrl});
  937. final String letter;
  938. final Color bg;
  939. final String? avatarUrl;
  940. @override
  941. Widget build(BuildContext context) {
  942. if (avatarUrl != null && avatarUrl!.isNotEmpty) {
  943. return ClipOval(
  944. child: Image.network(avatarUrl!,
  945. width: 64,
  946. height: 64,
  947. fit: BoxFit.cover,
  948. errorBuilder: (_, __, ___) => _fallback()),
  949. );
  950. }
  951. return _fallback();
  952. }
  953. Widget _fallback() => Container(
  954. width: 64,
  955. height: 64,
  956. decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
  957. child: Center(
  958. child: Text(letter,
  959. style: const TextStyle(
  960. color: Colors.white,
  961. fontSize: 24,
  962. fontWeight: FontWeight.w700)),
  963. ),
  964. );
  965. }
  966. // ── 标签 chip ─────────────────────────────────────────────────────────────────
  967. class _TagChip extends StatelessWidget {
  968. const _TagChip({required this.tag});
  969. final String tag;
  970. @override
  971. Widget build(BuildContext context) {
  972. return Container(
  973. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  974. decoration: BoxDecoration(
  975. color: AppColors.tagBlueBg,
  976. borderRadius: BorderRadius.circular(20),
  977. ),
  978. child: Text(tag,
  979. style: const TextStyle(color: AppColors.tagBlue, fontSize: 12)),
  980. );
  981. }
  982. }
  983. // ── 统计项 ────────────────────────────────────────────────────────────────────
  984. class _DetailStat extends StatelessWidget {
  985. const _DetailStat(
  986. {required this.label, required this.value, this.valueColor});
  987. final String label;
  988. final String value;
  989. final Color? valueColor;
  990. @override
  991. Widget build(BuildContext context) {
  992. final cs = Theme.of(context).colorScheme;
  993. return Expanded(
  994. child: Column(
  995. crossAxisAlignment: CrossAxisAlignment.start,
  996. children: [
  997. Text(label,
  998. style:
  999. TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)),
  1000. const SizedBox(height: 4),
  1001. Text(
  1002. value,
  1003. style: TextStyle(
  1004. color: valueColor ?? cs.onSurface,
  1005. fontSize: 15,
  1006. fontWeight: FontWeight.w600,
  1007. fontFeatures: const [FontFeature.tabularFigures()],
  1008. ),
  1009. ),
  1010. ],
  1011. ),
  1012. );
  1013. }
  1014. }
  1015. // ── 数量格式化 ─────────────────────────────────────────────────────────────────
  1016. String _fmtQty(dynamic raw) {
  1017. if (raw == null) return '--';
  1018. final str = raw.toString().trim();
  1019. if (str.isEmpty || double.tryParse(str) == null) return '--';
  1020. final isNeg = str.startsWith('-');
  1021. final absStr = isNeg ? str.substring(1) : str;
  1022. final dotIdx = absStr.indexOf('.');
  1023. String s;
  1024. if (dotIdx < 0) {
  1025. s = absStr;
  1026. } else {
  1027. final frac = absStr.substring(dotIdx + 1);
  1028. s = '${absStr.substring(0, dotIdx)}.${frac.length >= 4 ? frac.substring(0, 4) : frac.padRight(4, '0')}';
  1029. }
  1030. if (s.contains('.')) {
  1031. s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
  1032. }
  1033. final result = s.isEmpty ? '0' : s;
  1034. return isNeg ? '-$result' : result;
  1035. }
  1036. // ── 当前带单卡片 ───────────────────────────────────────────────────────────────
  1037. class _CurrentOrderCard extends StatelessWidget {
  1038. const _CurrentOrderCard({required this.order});
  1039. final Map<String, dynamic> order;
  1040. String _fmt(dynamic raw, {int decimals = 2}) {
  1041. if (raw == null) return '--';
  1042. final d = double.tryParse(raw.toString());
  1043. if (d == null) return '--';
  1044. return d.toStringAsFixed(decimals);
  1045. }
  1046. String _fmtPnl(dynamic raw, {int decimals = 2}) {
  1047. if (raw == null) return '--';
  1048. final d = double.tryParse(raw.toString());
  1049. if (d == null) return '--';
  1050. return '${d >= 0 ? '+' : ''}${d.toStringAsFixed(decimals)}';
  1051. }
  1052. Color _pnlColor(dynamic raw, ColorScheme cs) {
  1053. final d = double.tryParse(raw?.toString() ?? '');
  1054. if (d == null) return cs.onSurface;
  1055. return d >= 0 ? AppColors.rise : AppColors.fall;
  1056. }
  1057. String _fmtTimestamp(dynamic ts) {
  1058. if (ts == null) return '--';
  1059. final ms = int.tryParse(ts.toString());
  1060. if (ms == null) return ts.toString();
  1061. final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
  1062. .add(const Duration(hours: 8));
  1063. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  1064. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
  1065. }
  1066. @override
  1067. Widget build(BuildContext context) {
  1068. final cs = Theme.of(context).colorScheme;
  1069. final isDark = Theme.of(context).brightness == Brightness.dark;
  1070. final l10n = AppLocalizations.of(context)!;
  1071. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  1072. final symbol = order['symbol']?.toString() ?? '--';
  1073. final direction = order['direction']?.toString() ?? '';
  1074. final leverage = order['leverage']?.toString() ?? '--';
  1075. final marginType = order['positionType']?.toString() ??
  1076. order['marginType']?.toString() ??
  1077. l10n.crossMargin;
  1078. final openPrice = order['openPrice'] ?? order['avgOpenPrice'];
  1079. final currentPrice = order['currentPrice'] ?? order['markPrice'];
  1080. final margin = order['principalAmount'];
  1081. final quantity = order['totalPosition'];
  1082. final roi = order['profitRate'];
  1083. final pnl = order['profit'];
  1084. final openTimeStr = _fmtTimestamp(order['openTime']);
  1085. final positionId =
  1086. order['positionId']?.toString() ?? order['id']?.toString() ?? '';
  1087. final isLong = direction == '0';
  1088. final directionLabel =
  1089. isLong ? l10n.openLongBullish : l10n.openShortBearish;
  1090. final directionColor = isLong ? AppColors.rise : AppColors.fall;
  1091. final baseCoin = symbol.contains('/')
  1092. ? symbol.split('/')[0]
  1093. : symbol.replaceAll('USDT', '');
  1094. return Container(
  1095. margin: const EdgeInsets.only(bottom: 10),
  1096. padding: const EdgeInsets.all(14),
  1097. decoration:
  1098. BoxDecoration(color: cardBg, borderRadius: BorderRadius.circular(10)),
  1099. child: Column(
  1100. crossAxisAlignment: CrossAxisAlignment.start,
  1101. children: [
  1102. // 标题行
  1103. Row(
  1104. children: [
  1105. Expanded(
  1106. child: Text('$symbol ${l10n.perpetual}',
  1107. style: TextStyle(
  1108. color: cs.onSurface,
  1109. fontSize: 14,
  1110. fontWeight: FontWeight.w700)),
  1111. ),
  1112. _Badge(
  1113. label: directionLabel,
  1114. bgColor: directionColor.withValues(alpha: 0.15),
  1115. textColor: directionColor),
  1116. const SizedBox(width: 6),
  1117. _Badge(
  1118. label: '${leverage}x',
  1119. bgColor: cs.onSurface.withAlpha(20),
  1120. textColor: cs.onSurface.withAlpha(180),
  1121. borderColor: cs.onSurface.withAlpha(60)),
  1122. const SizedBox(width: 6),
  1123. _Badge(
  1124. label: marginType,
  1125. bgColor: cs.onSurface.withAlpha(20),
  1126. textColor: cs.onSurface.withAlpha(153),
  1127. borderColor: cs.onSurface.withAlpha(60)),
  1128. ],
  1129. ),
  1130. const SizedBox(height: 12),
  1131. Row(
  1132. children: [
  1133. _OrderStat(label: l10n.openAvgPriceUsdt, value: _fmt(openPrice)),
  1134. _OrderStat(
  1135. label: l10n.currentPriceUsdt, value: _fmt(currentPrice)),
  1136. _OrderStat(label: l10n.currentMarginUsdt, value: _fmt(margin)),
  1137. ],
  1138. ),
  1139. const SizedBox(height: 10),
  1140. Row(
  1141. children: [
  1142. _OrderStat(
  1143. label: l10n.qtyWithCoin(baseCoin), value: _fmtQty(quantity)),
  1144. _OrderStat(
  1145. label: l10n.returnRate,
  1146. value: roi == null ? '--' : '${_fmtPnl(roi)}%',
  1147. valueColor: _pnlColor(roi, cs)),
  1148. _OrderStat(
  1149. label: l10n.profitUsdt,
  1150. value: _fmtPnl(pnl),
  1151. valueColor: _pnlColor(pnl, cs)),
  1152. ],
  1153. ),
  1154. const SizedBox(height: 10),
  1155. Divider(height: 1, thickness: 0.5, color: cs.outlineVariant),
  1156. const SizedBox(height: 8),
  1157. Row(
  1158. children: [
  1159. Text(l10n.openTimeWithValue(openTimeStr),
  1160. style: TextStyle(
  1161. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1162. const Spacer(),
  1163. if (positionId.isNotEmpty)
  1164. Row(
  1165. children: [
  1166. Text('${l10n.positionIdPrefix}$positionId',
  1167. style: TextStyle(
  1168. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1169. const SizedBox(width: 4),
  1170. GestureDetector(
  1171. onTap: () {
  1172. Clipboard.setData(ClipboardData(text: positionId));
  1173. showTopToast(context,
  1174. message: l10n.copyPositionIdSuccess,
  1175. backgroundColor: AppColors.rise);
  1176. },
  1177. child: Icon(Icons.copy_outlined,
  1178. size: 13, color: cs.onSurface.withAlpha(100)),
  1179. ),
  1180. ],
  1181. ),
  1182. ],
  1183. ),
  1184. ],
  1185. ),
  1186. );
  1187. }
  1188. }
  1189. // ── 历史带单卡片 ───────────────────────────────────────────────────────────────
  1190. class _HistoryOrderCard extends StatelessWidget {
  1191. const _HistoryOrderCard({required this.order});
  1192. final Map<String, dynamic> order;
  1193. String _fmt(dynamic raw, {int decimals = 2}) {
  1194. if (raw == null) return '--';
  1195. final d = double.tryParse(raw.toString());
  1196. if (d == null) return '--';
  1197. return d.toStringAsFixed(decimals);
  1198. }
  1199. String _fmtPnl(dynamic raw, {int decimals = 2}) {
  1200. if (raw == null) return '--';
  1201. final d = double.tryParse(raw.toString());
  1202. if (d == null) return '--';
  1203. return '${d >= 0 ? '+' : ''}${d.toStringAsFixed(decimals)}';
  1204. }
  1205. Color _pnlColor(dynamic raw, ColorScheme cs) {
  1206. final d = double.tryParse(raw?.toString() ?? '');
  1207. if (d == null) return cs.onSurface;
  1208. return d >= 0 ? AppColors.rise : AppColors.fall;
  1209. }
  1210. String _fmtTimestamp(dynamic ts) {
  1211. if (ts == null) return '--';
  1212. final ms = int.tryParse(ts.toString());
  1213. if (ms == null) return ts.toString();
  1214. final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
  1215. .add(const Duration(hours: 8));
  1216. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  1217. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
  1218. }
  1219. void _showShareSheet(BuildContext context) {
  1220. showModalBottomSheet(
  1221. context: context,
  1222. useRootNavigator: true,
  1223. isScrollControlled: true,
  1224. backgroundColor: Colors.transparent,
  1225. builder: (_) => _ShareOrderSheet(order: order, fmt: _fmt),
  1226. );
  1227. }
  1228. @override
  1229. Widget build(BuildContext context) {
  1230. final cs = Theme.of(context).colorScheme;
  1231. final isDark = Theme.of(context).brightness == Brightness.dark;
  1232. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  1233. final l10n = AppLocalizations.of(context)!;
  1234. final symbol = order['symbol']?.toString() ?? '--';
  1235. final direction = order['direction']?.toString() ?? '';
  1236. final leverage = order['leverage']?.toString() ?? '--';
  1237. final marginType = order['positionType']?.toString() ??
  1238. order['marginType']?.toString() ??
  1239. l10n.crossMargin;
  1240. final openPrice = order['openPrice'] ?? order['avgOpenPrice'];
  1241. final closePrice = order['closePrice'] ?? order['avgClosePrice'];
  1242. final quantity = order['totalPosition'];
  1243. final pnl = order['profit'];
  1244. final roi = order['profitRate'];
  1245. final openTime = _fmtTimestamp(order['openTime']);
  1246. final closeTime = _fmtTimestamp(order['closeTime']);
  1247. final isLong = direction == '0';
  1248. final directionLabel =
  1249. isLong ? l10n.openLongBullish : l10n.openShortBearish;
  1250. final directionColor = isLong ? AppColors.rise : AppColors.fall;
  1251. final baseCoin = symbol.contains('/')
  1252. ? symbol.split('/')[0]
  1253. : symbol.replaceAll('USDT', '');
  1254. return Container(
  1255. margin: const EdgeInsets.only(bottom: 10),
  1256. padding: const EdgeInsets.all(14),
  1257. decoration:
  1258. BoxDecoration(color: cardBg, borderRadius: BorderRadius.circular(10)),
  1259. child: Column(
  1260. crossAxisAlignment: CrossAxisAlignment.start,
  1261. children: [
  1262. // 标题行
  1263. Row(
  1264. children: [
  1265. Expanded(
  1266. child: Text('$symbol ${l10n.perpetual}',
  1267. style: TextStyle(
  1268. color: cs.onSurface,
  1269. fontSize: 14,
  1270. fontWeight: FontWeight.w700)),
  1271. ),
  1272. GestureDetector(
  1273. onTap: () => _showShareSheet(context),
  1274. child: Icon(Icons.open_in_new,
  1275. size: 14, color: cs.onSurface.withAlpha(100)),
  1276. ),
  1277. ],
  1278. ),
  1279. const SizedBox(height: 8),
  1280. // 徽章行
  1281. Row(
  1282. children: [
  1283. _Badge(
  1284. label: directionLabel,
  1285. bgColor: directionColor.withValues(alpha: 0.15),
  1286. textColor: directionColor),
  1287. const SizedBox(width: 6),
  1288. _Badge(
  1289. label: '${leverage}x',
  1290. bgColor: cs.onSurface.withAlpha(20),
  1291. textColor: cs.onSurface.withAlpha(180),
  1292. borderColor: cs.onSurface.withAlpha(60)),
  1293. const SizedBox(width: 6),
  1294. _Badge(
  1295. label: marginType,
  1296. bgColor: cs.onSurface.withAlpha(20),
  1297. textColor: cs.onSurface.withAlpha(153),
  1298. borderColor: cs.onSurface.withAlpha(60)),
  1299. ],
  1300. ),
  1301. const SizedBox(height: 12),
  1302. // 数据行 1: 数量 + 收益 + 收益率
  1303. Row(
  1304. children: [
  1305. _OrderStat(
  1306. label: l10n.qtyWithCoin(baseCoin), value: _fmtQty(quantity)),
  1307. _OrderStat(
  1308. label: l10n.profitUsdt,
  1309. value: _fmtPnl(pnl),
  1310. valueColor: _pnlColor(pnl, cs)),
  1311. _OrderStat(
  1312. label: l10n.returnRate,
  1313. value: roi == null ? '--' : '${_fmtPnl(roi)}%',
  1314. valueColor: _pnlColor(roi, cs)),
  1315. ],
  1316. ),
  1317. const SizedBox(height: 10),
  1318. // 数据行 2: 开仓均价 + 平仓均价
  1319. Row(
  1320. children: [
  1321. _OrderStat(label: l10n.openAvgPriceUsdt, value: _fmt(openPrice)),
  1322. _OrderStat(
  1323. label: l10n.closeAvgPriceUsdt, value: _fmt(closePrice)),
  1324. const Expanded(child: SizedBox()),
  1325. ],
  1326. ),
  1327. const SizedBox(height: 10),
  1328. Divider(height: 1, thickness: 0.5, color: cs.outlineVariant),
  1329. const SizedBox(height: 8),
  1330. Row(
  1331. children: [
  1332. Text(openTime,
  1333. style: TextStyle(
  1334. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1335. const Spacer(),
  1336. Text(closeTime,
  1337. style: TextStyle(
  1338. color: cs.onSurface.withAlpha(120), fontSize: 11)),
  1339. ],
  1340. ),
  1341. ],
  1342. ),
  1343. );
  1344. }
  1345. }
  1346. // ── Badge ─────────────────────────────────────────────────────────────────────
  1347. class _Badge extends StatelessWidget {
  1348. const _Badge({
  1349. required this.label,
  1350. required this.bgColor,
  1351. required this.textColor,
  1352. this.borderColor,
  1353. });
  1354. final String label;
  1355. final Color bgColor;
  1356. final Color textColor;
  1357. final Color? borderColor;
  1358. @override
  1359. Widget build(BuildContext context) {
  1360. return Container(
  1361. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  1362. decoration: BoxDecoration(
  1363. color: bgColor,
  1364. borderRadius: BorderRadius.circular(4),
  1365. border: borderColor != null
  1366. ? Border.all(color: borderColor!, width: 0.8)
  1367. : null,
  1368. ),
  1369. child: Text(label,
  1370. style: TextStyle(
  1371. color: textColor, fontSize: 11, fontWeight: FontWeight.w600)),
  1372. );
  1373. }
  1374. }
  1375. // ── OrderStat ─────────────────────────────────────────────────────────────────
  1376. class _OrderStat extends StatelessWidget {
  1377. const _OrderStat({required this.label, required this.value, this.valueColor});
  1378. final String label;
  1379. final String value;
  1380. final Color? valueColor;
  1381. @override
  1382. Widget build(BuildContext context) {
  1383. final cs = Theme.of(context).colorScheme;
  1384. return Expanded(
  1385. child: Column(
  1386. crossAxisAlignment: CrossAxisAlignment.start,
  1387. children: [
  1388. Text(label,
  1389. style:
  1390. TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1391. const SizedBox(height: 2),
  1392. Text(value,
  1393. style: TextStyle(
  1394. color: valueColor ?? cs.onSurface,
  1395. fontSize: 13,
  1396. fontWeight: FontWeight.w600,
  1397. fontFeatures: const [FontFeature.tabularFigures()],
  1398. )),
  1399. ],
  1400. ),
  1401. );
  1402. }
  1403. }
  1404. // ── 底部按钮 ──────────────────────────────────────────────────────────────────
  1405. class _BottomButton extends StatelessWidget {
  1406. const _BottomButton({
  1407. required this.isFollowing,
  1408. required this.loading,
  1409. required this.onTap,
  1410. this.isFull = false,
  1411. });
  1412. final bool isFollowing;
  1413. final bool isFull;
  1414. final bool loading;
  1415. final VoidCallback onTap;
  1416. @override
  1417. Widget build(BuildContext context) {
  1418. final cs = Theme.of(context).colorScheme;
  1419. final isDark = Theme.of(context).brightness == Brightness.dark;
  1420. return Container(
  1421. padding: EdgeInsets.fromLTRB(
  1422. 16, 12, 16, 12 + MediaQuery.of(context).padding.bottom),
  1423. decoration: BoxDecoration(
  1424. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1425. border: Border(
  1426. top: BorderSide(
  1427. color:
  1428. isDark ? AppColors.darkDivider : AppColors.lightDivider)),
  1429. ),
  1430. child: SizedBox(
  1431. width: double.infinity,
  1432. height: 48,
  1433. child: loading
  1434. ? OutlinedButton(
  1435. onPressed: null,
  1436. style: OutlinedButton.styleFrom(
  1437. side: BorderSide(color: cs.outline.withAlpha(50)),
  1438. shape: const StadiumBorder()),
  1439. child: const SizedBox(
  1440. width: 20,
  1441. height: 20,
  1442. child: CircularProgressIndicator(strokeWidth: 2)),
  1443. )
  1444. : isFull
  1445. ? ElevatedButton(
  1446. onPressed: null,
  1447. style: ElevatedButton.styleFrom(
  1448. backgroundColor: cs.outline.withAlpha(40),
  1449. foregroundColor: cs.onSurface.withAlpha(100),
  1450. elevation: 0,
  1451. shape: const StadiumBorder(),
  1452. disabledBackgroundColor: cs.outline.withAlpha(40),
  1453. disabledForegroundColor: cs.onSurface.withAlpha(100),
  1454. ),
  1455. child: Text(AppLocalizations.of(context)!.fullCapacity,
  1456. style: const TextStyle(
  1457. fontSize: 15, fontWeight: FontWeight.w600)),
  1458. )
  1459. : isFollowing
  1460. ? ElevatedButton(
  1461. onPressed: onTap,
  1462. style: ElevatedButton.styleFrom(
  1463. backgroundColor: isDark
  1464. ? AppColors.darkBgTertiary
  1465. : AppColors.lightBgTertiary,
  1466. foregroundColor: cs.onSurface,
  1467. elevation: 0,
  1468. shape: const StadiumBorder(),
  1469. ),
  1470. child: Text(AppLocalizations.of(context)!.unfollow,
  1471. style: const TextStyle(
  1472. fontSize: 15, fontWeight: FontWeight.w600)),
  1473. )
  1474. : ElevatedButton(
  1475. onPressed: onTap,
  1476. style: ElevatedButton.styleFrom(
  1477. backgroundColor: AppColors.brand,
  1478. foregroundColor: Colors.black,
  1479. shape: const StadiumBorder(),
  1480. elevation: 0,
  1481. ),
  1482. child: Text(AppLocalizations.of(context)!.followTrader,
  1483. style: const TextStyle(
  1484. fontSize: 15, fontWeight: FontWeight.w600)),
  1485. ),
  1486. ),
  1487. );
  1488. }
  1489. }
  1490. // ── 骨架屏 ────────────────────────────────────────────────────────────────────
  1491. class _TraderDetailSkeleton extends StatelessWidget {
  1492. const _TraderDetailSkeleton();
  1493. @override
  1494. Widget build(BuildContext context) {
  1495. final cs = Theme.of(context).colorScheme;
  1496. final isDark = Theme.of(context).brightness == Brightness.dark;
  1497. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  1498. return AppShimmer(
  1499. child: SingleChildScrollView(
  1500. physics: const NeverScrollableScrollPhysics(),
  1501. child: Column(
  1502. crossAxisAlignment: CrossAxisAlignment.start,
  1503. children: [
  1504. // Profile card
  1505. Container(
  1506. color: cardBg,
  1507. padding: const EdgeInsets.all(16),
  1508. child: Row(
  1509. crossAxisAlignment: CrossAxisAlignment.center,
  1510. children: [
  1511. shimmerCircle(64),
  1512. const SizedBox(width: 14),
  1513. Expanded(
  1514. child: Column(
  1515. crossAxisAlignment: CrossAxisAlignment.start,
  1516. children: [
  1517. shimmerBox(140, 18),
  1518. const SizedBox(height: 10),
  1519. shimmerBox(200, 13),
  1520. ],
  1521. ),
  1522. ),
  1523. ],
  1524. ),
  1525. ),
  1526. const SizedBox(height: 8),
  1527. // 账户信息 card
  1528. Container(
  1529. margin: const EdgeInsets.fromLTRB(12, 0, 12, 0),
  1530. padding: const EdgeInsets.all(16),
  1531. decoration: BoxDecoration(
  1532. color: cardBg, borderRadius: BorderRadius.circular(12)),
  1533. child: Column(
  1534. crossAxisAlignment: CrossAxisAlignment.start,
  1535. children: [
  1536. shimmerBox(60, 14),
  1537. const SizedBox(height: 14),
  1538. Row(children: [
  1539. Expanded(
  1540. child: Column(
  1541. crossAxisAlignment: CrossAxisAlignment.start,
  1542. children: [
  1543. shimmerBox(120, 12),
  1544. const SizedBox(height: 6),
  1545. shimmerBox(80, 15),
  1546. ])),
  1547. Expanded(
  1548. child: Column(
  1549. crossAxisAlignment: CrossAxisAlignment.start,
  1550. children: [
  1551. shimmerBox(100, 12),
  1552. const SizedBox(height: 6),
  1553. shimmerBox(60, 15),
  1554. ])),
  1555. ]),
  1556. const SizedBox(height: 14),
  1557. Row(children: [
  1558. Expanded(
  1559. child: Column(
  1560. crossAxisAlignment: CrossAxisAlignment.start,
  1561. children: [
  1562. shimmerBox(80, 12),
  1563. const SizedBox(height: 6),
  1564. shimmerBox(50, 15),
  1565. ])),
  1566. Expanded(
  1567. child: Column(
  1568. crossAxisAlignment: CrossAxisAlignment.start,
  1569. children: [
  1570. shimmerBox(80, 12),
  1571. const SizedBox(height: 6),
  1572. shimmerBox(40, 15),
  1573. ])),
  1574. ]),
  1575. ],
  1576. ),
  1577. ),
  1578. const SizedBox(height: 8),
  1579. // 核心数据 card
  1580. Container(
  1581. margin: const EdgeInsets.fromLTRB(12, 0, 12, 0),
  1582. padding: const EdgeInsets.all(16),
  1583. decoration: BoxDecoration(
  1584. color: cardBg, borderRadius: BorderRadius.circular(12)),
  1585. child: Column(
  1586. crossAxisAlignment: CrossAxisAlignment.start,
  1587. children: [
  1588. shimmerBox(60, 14),
  1589. const SizedBox(height: 14),
  1590. Row(children: [
  1591. Expanded(
  1592. child: Column(
  1593. crossAxisAlignment: CrossAxisAlignment.start,
  1594. children: [
  1595. shimmerBox(80, 12),
  1596. const SizedBox(height: 6),
  1597. shimmerBox(70, 15),
  1598. ])),
  1599. Expanded(
  1600. child: Column(
  1601. crossAxisAlignment: CrossAxisAlignment.start,
  1602. children: [
  1603. shimmerBox(100, 12),
  1604. const SizedBox(height: 6),
  1605. shimmerBox(70, 15),
  1606. ])),
  1607. ]),
  1608. ],
  1609. ),
  1610. ),
  1611. const SizedBox(height: 16),
  1612. // Tab bar skeleton
  1613. Padding(
  1614. padding: const EdgeInsets.symmetric(horizontal: 16),
  1615. child: Row(children: [
  1616. Expanded(child: shimmerFill(32, radius: 4)),
  1617. const SizedBox(width: 16),
  1618. Expanded(child: shimmerFill(32, radius: 4)),
  1619. ]),
  1620. ),
  1621. const SizedBox(height: 16),
  1622. // 订单卡片骨架
  1623. ...List.generate(
  1624. 3,
  1625. (_) => Container(
  1626. margin: const EdgeInsets.fromLTRB(12, 0, 12, 10),
  1627. padding: const EdgeInsets.all(14),
  1628. decoration: BoxDecoration(
  1629. color: cs.onSurface.withAlpha(8),
  1630. borderRadius: BorderRadius.circular(10)),
  1631. child: Column(
  1632. crossAxisAlignment: CrossAxisAlignment.start,
  1633. children: [
  1634. Row(children: [
  1635. Expanded(child: shimmerBox(100, 14)),
  1636. shimmerBox(60, 20, radius: 4),
  1637. ]),
  1638. const SizedBox(height: 10),
  1639. Row(
  1640. children: List.generate(
  1641. 3,
  1642. (i) => Expanded(
  1643. child: Padding(
  1644. padding:
  1645. EdgeInsets.only(right: i < 2 ? 8.0 : 0),
  1646. child: shimmerBox(double.infinity, 32,
  1647. radius: 4),
  1648. ),
  1649. ))),
  1650. ],
  1651. ),
  1652. ),
  1653. ),
  1654. ],
  1655. ),
  1656. ),
  1657. );
  1658. }
  1659. }
  1660. // ── 分享带单 BottomSheet ───────────────────────────────────
  1661. class _ShareOrderSheet extends ConsumerStatefulWidget {
  1662. const _ShareOrderSheet({required this.order, required this.fmt});
  1663. final Map<String, dynamic> order;
  1664. final String Function(dynamic, {int decimals}) fmt;
  1665. @override
  1666. ConsumerState<_ShareOrderSheet> createState() => _ShareOrderSheetState();
  1667. }
  1668. class _ShareOrderSheetState extends ConsumerState<_ShareOrderSheet> {
  1669. final _cardKey = GlobalKey();
  1670. bool _sharing = false;
  1671. bool _saving = false;
  1672. String? _inviteCode;
  1673. String? _inviteUrl;
  1674. @override
  1675. void initState() {
  1676. super.initState();
  1677. _loadInviteInfo();
  1678. }
  1679. Future<void> _loadInviteInfo() async {
  1680. try {
  1681. final dio = ref.read(dioClientProvider);
  1682. final data = await AuthService(dio).getMyInfo();
  1683. final prefix = data['promotionPrefix']?.toString() ?? '';
  1684. final code = data['promotionCode']?.toString() ?? '';
  1685. final url =
  1686. (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null;
  1687. if (mounted) {
  1688. setState(() {
  1689. _inviteCode = code.isNotEmpty ? code : null;
  1690. _inviteUrl = url;
  1691. });
  1692. }
  1693. } catch (_) {}
  1694. }
  1695. String _fmtTimestamp(dynamic ts) {
  1696. if (ts == null) return '--';
  1697. final ms = int.tryParse(ts.toString());
  1698. if (ms == null) return ts.toString();
  1699. final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
  1700. .add(const Duration(hours: 8));
  1701. return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  1702. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
  1703. }
  1704. Future<Uint8List?> _renderCard() async {
  1705. final boundary =
  1706. _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
  1707. if (boundary == null) return null;
  1708. final image = await boundary.toImage(pixelRatio: 3.0);
  1709. final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  1710. return byteData?.buffer.asUint8List();
  1711. }
  1712. Future<void> _doSave(BuildContext context) async {
  1713. setState(() => _saving = true);
  1714. try {
  1715. final bytes = await _renderCard();
  1716. if (bytes == null) return;
  1717. await Gal.requestAccess();
  1718. await Gal.putImageBytes(
  1719. bytes,
  1720. name: 'trade_share_${DateTime.now().millisecondsSinceEpoch}',
  1721. );
  1722. if (!context.mounted) return;
  1723. showTopToast(context,
  1724. message: AppLocalizations.of(context)!.saveSuccess,
  1725. backgroundColor: AppColors.rise);
  1726. } on GalException catch (e) {
  1727. if (!context.mounted) return;
  1728. final l10n = AppLocalizations.of(context)!;
  1729. if (e.type == GalExceptionType.accessDenied) {
  1730. showTopToast(context,
  1731. message: l10n.photoPermissionDenied,
  1732. backgroundColor: AppColors.fall);
  1733. } else {
  1734. showTopToast(context,
  1735. message: l10n.saveFailed, backgroundColor: AppColors.fall);
  1736. }
  1737. } catch (e) {
  1738. if (context.mounted) {
  1739. showTopToast(context,
  1740. message: AppLocalizations.of(context)!.saveFailed,
  1741. backgroundColor: AppColors.fall);
  1742. }
  1743. } finally {
  1744. if (mounted) setState(() => _saving = false);
  1745. }
  1746. }
  1747. Future<void> _doShare(BuildContext context) async {
  1748. setState(() => _sharing = true);
  1749. try {
  1750. final bytes = await _renderCard();
  1751. if (bytes == null) return;
  1752. final tmpDir = await getTemporaryDirectory();
  1753. final file = File(
  1754. '${tmpDir.path}/trade_share_${DateTime.now().millisecondsSinceEpoch}.png');
  1755. await file.writeAsBytes(bytes);
  1756. if (!context.mounted) return;
  1757. Navigator.of(context).pop();
  1758. await Share.shareXFiles(
  1759. [XFile(file.path, mimeType: 'image/png')],
  1760. subject: AppLocalizations.of(context)!.myTradingProfit,
  1761. );
  1762. } catch (e) {
  1763. if (context.mounted) {
  1764. showTopToast(context,
  1765. message: AppLocalizations.of(context)!.shareFailed,
  1766. backgroundColor: AppColors.fall);
  1767. }
  1768. } finally {
  1769. if (mounted) setState(() => _sharing = false);
  1770. }
  1771. }
  1772. @override
  1773. Widget build(BuildContext context) {
  1774. final cs = Theme.of(context).colorScheme;
  1775. final isDark = Theme.of(context).brightness == Brightness.dark;
  1776. final order = widget.order;
  1777. final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
  1778. final pnlPositive = profit >= 0;
  1779. final l10n = AppLocalizations.of(context)!;
  1780. final symbol = order['symbol']?.toString() ?? '--';
  1781. final isLong = (order['direction']?.toString() ?? '0') == '0';
  1782. final leverage = order['leverage']?.toString() ?? '--';
  1783. final profitRateRaw =
  1784. double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
  1785. final profitRateStr =
  1786. '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
  1787. final openPrice = widget.fmt(order['openPrice']);
  1788. final closePrice = widget.fmt(order['closePrice']);
  1789. final openTime = _fmtTimestamp(order['openTime']);
  1790. final closeTime = _fmtTimestamp(order['closeTime']);
  1791. return Container(
  1792. decoration: BoxDecoration(
  1793. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  1794. borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  1795. ),
  1796. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  1797. child: Column(
  1798. mainAxisSize: MainAxisSize.min,
  1799. children: [
  1800. // 拖拽指示条
  1801. Container(
  1802. width: 36,
  1803. height: 4,
  1804. decoration: BoxDecoration(
  1805. color: cs.onSurface.withAlpha(60),
  1806. borderRadius: BorderRadius.circular(2),
  1807. ),
  1808. ),
  1809. const SizedBox(height: 16),
  1810. // 分享卡片预览
  1811. RepaintBoundary(
  1812. key: _cardKey,
  1813. child: _TradeShareCard(
  1814. symbol: symbol,
  1815. isLong: isLong,
  1816. leverage: leverage,
  1817. profitRateStr: profitRateStr,
  1818. pnlPositive: pnlPositive,
  1819. openPrice: openPrice,
  1820. closePrice: closePrice,
  1821. openTime: openTime,
  1822. closeTime: closeTime,
  1823. inviteCode: _inviteCode,
  1824. inviteUrl: _inviteUrl,
  1825. ),
  1826. ),
  1827. const SizedBox(height: 24),
  1828. // 操作按钮行:取消 | 保存海报 | 分享
  1829. Row(
  1830. children: [
  1831. Expanded(
  1832. child: OutlinedButton(
  1833. onPressed: () => Navigator.of(context).pop(),
  1834. style: OutlinedButton.styleFrom(
  1835. padding: const EdgeInsets.symmetric(vertical: 12),
  1836. shape: RoundedRectangleBorder(
  1837. borderRadius: BorderRadius.circular(8)),
  1838. ),
  1839. child: Text(l10n.cancelLabel,
  1840. style: TextStyle(color: cs.onSurface, fontSize: 14)),
  1841. ),
  1842. ),
  1843. const SizedBox(width: 8),
  1844. Expanded(
  1845. child: OutlinedButton(
  1846. onPressed: _saving ? null : () => _doSave(context),
  1847. style: OutlinedButton.styleFrom(
  1848. padding: const EdgeInsets.symmetric(vertical: 12),
  1849. shape: RoundedRectangleBorder(
  1850. borderRadius: BorderRadius.circular(8)),
  1851. ),
  1852. child: _saving
  1853. ? SizedBox(
  1854. width: 16,
  1855. height: 16,
  1856. child: CircularProgressIndicator(
  1857. strokeWidth: 2,
  1858. color: cs.onSurface.withAlpha(153)),
  1859. )
  1860. : Text(l10n.savePoster,
  1861. style: TextStyle(color: cs.onSurface, fontSize: 14)),
  1862. ),
  1863. ),
  1864. const SizedBox(width: 8),
  1865. Expanded(
  1866. child: ElevatedButton(
  1867. onPressed: _sharing ? null : () => _doShare(context),
  1868. style: ElevatedButton.styleFrom(
  1869. backgroundColor:
  1870. pnlPositive ? AppColors.rise : AppColors.fall,
  1871. padding: const EdgeInsets.symmetric(vertical: 12),
  1872. shape: RoundedRectangleBorder(
  1873. borderRadius: BorderRadius.circular(8)),
  1874. elevation: 0,
  1875. ),
  1876. child: _sharing
  1877. ? const SizedBox(
  1878. width: 16,
  1879. height: 16,
  1880. child: CircularProgressIndicator(
  1881. strokeWidth: 2, color: Colors.white),
  1882. )
  1883. : Text(l10n.shareLabel,
  1884. style: const TextStyle(
  1885. color: Colors.white,
  1886. fontSize: 14,
  1887. fontWeight: FontWeight.w600)),
  1888. ),
  1889. ),
  1890. ],
  1891. ),
  1892. ],
  1893. ),
  1894. );
  1895. }
  1896. }
  1897. // ── 带单分享卡片内容 ─────────────────────────────────────────
  1898. class _TradeShareCard extends StatelessWidget {
  1899. const _TradeShareCard({
  1900. required this.symbol,
  1901. required this.isLong,
  1902. required this.leverage,
  1903. required this.profitRateStr,
  1904. required this.pnlPositive,
  1905. required this.openPrice,
  1906. required this.closePrice,
  1907. required this.openTime,
  1908. required this.closeTime,
  1909. this.inviteCode,
  1910. this.inviteUrl,
  1911. });
  1912. final String symbol;
  1913. final bool isLong;
  1914. final String leverage;
  1915. final String profitRateStr;
  1916. final bool pnlPositive;
  1917. final String openPrice;
  1918. final String closePrice;
  1919. final String openTime;
  1920. final String closeTime;
  1921. final String? inviteCode;
  1922. final String? inviteUrl;
  1923. String _baseCoin(String sym) {
  1924. if (sym.contains('/')) return sym.split('/').first;
  1925. return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
  1926. }
  1927. @override
  1928. Widget build(BuildContext context) {
  1929. final isDark = Theme.of(context).brightness == Brightness.dark;
  1930. final l10n = AppLocalizations.of(context)!;
  1931. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  1932. final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall;
  1933. final coinSymbol = _baseCoin(symbol);
  1934. // 主题色变量
  1935. final bgColors = isDark
  1936. ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)]
  1937. : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)];
  1938. final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E);
  1939. final textSecondary = isDark
  1940. ? Colors.white.withAlpha(120)
  1941. : const Color(0xFF1A1F2E).withAlpha(120);
  1942. final textMuted = isDark
  1943. ? Colors.white.withAlpha(80)
  1944. : const Color(0xFF1A1F2E).withAlpha(80);
  1945. final borderColor = isDark
  1946. ? Colors.white.withAlpha(40)
  1947. : const Color(0xFF1A1F2E).withAlpha(30);
  1948. final qrFgColor = isDark ? Colors.white : Colors.black;
  1949. final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white;
  1950. return Container(
  1951. width: double.infinity,
  1952. decoration: BoxDecoration(
  1953. gradient: LinearGradient(
  1954. begin: Alignment.topLeft,
  1955. end: Alignment.bottomRight,
  1956. colors: bgColors,
  1957. ),
  1958. borderRadius: BorderRadius.circular(16),
  1959. ),
  1960. clipBehavior: Clip.antiAlias,
  1961. child: Padding(
  1962. padding: const EdgeInsets.all(20),
  1963. child: Column(
  1964. crossAxisAlignment: CrossAxisAlignment.start,
  1965. children: [
  1966. // LOGO + 品牌名
  1967. Row(
  1968. children: [
  1969. Image.asset(
  1970. 'assets/images/app_icon.png',
  1971. height: 28,
  1972. width: 28,
  1973. errorBuilder: (_, __, ___) => const SizedBox.shrink(),
  1974. ),
  1975. const SizedBox(width: 8),
  1976. Text(
  1977. 'iBit',
  1978. style: TextStyle(
  1979. color: textPrimary,
  1980. fontSize: 14,
  1981. fontWeight: FontWeight.w700,
  1982. letterSpacing: 0.5),
  1983. ),
  1984. ],
  1985. ),
  1986. const SizedBox(height: 14),
  1987. // 币对 + 永续 tag
  1988. Row(
  1989. children: [
  1990. Text(
  1991. '${coinSymbol}USDT',
  1992. style: TextStyle(
  1993. color: textPrimary,
  1994. fontSize: 22,
  1995. fontWeight: FontWeight.w800),
  1996. ),
  1997. const SizedBox(width: 8),
  1998. Container(
  1999. padding:
  2000. const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
  2001. decoration: BoxDecoration(
  2002. color: const Color(0xFFFFAB00),
  2003. borderRadius: BorderRadius.circular(4),
  2004. ),
  2005. child: Text(l10n.perpetual,
  2006. style: const TextStyle(
  2007. color: Colors.white,
  2008. fontSize: 11,
  2009. fontWeight: FontWeight.w700)),
  2010. ),
  2011. ],
  2012. ),
  2013. const SizedBox(height: 4),
  2014. // 方向 + 杠杆
  2015. Text(
  2016. '${isLong ? l10n.openLong : l10n.openShort} ${leverage}X',
  2017. style: TextStyle(
  2018. color: sideColor, fontSize: 15, fontWeight: FontWeight.w700),
  2019. ),
  2020. const SizedBox(height: 14),
  2021. // 收益率(大字)
  2022. Text(l10n.returnRate,
  2023. style: TextStyle(color: textSecondary, fontSize: 12)),
  2024. const SizedBox(height: 4),
  2025. Text(profitRateStr,
  2026. style: TextStyle(
  2027. color: pnlColor,
  2028. fontSize: 36,
  2029. fontWeight: FontWeight.w800,
  2030. letterSpacing: -0.5)),
  2031. const SizedBox(height: 16),
  2032. // 开仓均价 + 平仓均价
  2033. Row(
  2034. children: [
  2035. Expanded(
  2036. child: Column(
  2037. crossAxisAlignment: CrossAxisAlignment.start,
  2038. children: [
  2039. Text(l10n.openAvgPrice,
  2040. style: TextStyle(color: textSecondary, fontSize: 11)),
  2041. const SizedBox(height: 2),
  2042. Text(openPrice,
  2043. style: TextStyle(
  2044. color: textPrimary,
  2045. fontSize: 13,
  2046. fontWeight: FontWeight.w600)),
  2047. ],
  2048. ),
  2049. ),
  2050. Expanded(
  2051. child: Column(
  2052. crossAxisAlignment: CrossAxisAlignment.end,
  2053. children: [
  2054. Text(l10n.avgClosePrice,
  2055. style: TextStyle(color: textSecondary, fontSize: 11)),
  2056. const SizedBox(height: 2),
  2057. Text(closePrice,
  2058. style: TextStyle(
  2059. color: textPrimary,
  2060. fontSize: 13,
  2061. fontWeight: FontWeight.w600)),
  2062. ],
  2063. ),
  2064. ),
  2065. ],
  2066. ),
  2067. const SizedBox(height: 10),
  2068. // 时间
  2069. Text(closeTime != '--' ? closeTime : openTime,
  2070. style: TextStyle(color: textMuted, fontSize: 11)),
  2071. const SizedBox(height: 14),
  2072. // 分隔线
  2073. Divider(color: borderColor, height: 1),
  2074. const SizedBox(height: 14),
  2075. // 邀请码 + 二维码
  2076. Row(
  2077. crossAxisAlignment: CrossAxisAlignment.center,
  2078. children: [
  2079. Expanded(
  2080. child: Column(
  2081. crossAxisAlignment: CrossAxisAlignment.start,
  2082. children: [
  2083. if (inviteCode != null)
  2084. RichText(
  2085. text: TextSpan(
  2086. style: const TextStyle(fontSize: 15),
  2087. children: [
  2088. TextSpan(
  2089. text: l10n.inviteCodeLabel,
  2090. style: TextStyle(color: textSecondary),
  2091. ),
  2092. TextSpan(
  2093. text: inviteCode!,
  2094. style: const TextStyle(
  2095. color: AppColors.brand,
  2096. fontWeight: FontWeight.w700),
  2097. ),
  2098. ],
  2099. ),
  2100. ),
  2101. const SizedBox(height: 4),
  2102. Text(l10n.registerAndEarnRebate,
  2103. style: TextStyle(color: textMuted, fontSize: 12)),
  2104. ],
  2105. ),
  2106. ),
  2107. Container(
  2108. decoration: BoxDecoration(
  2109. border: Border.all(color: borderColor, width: 1),
  2110. borderRadius: BorderRadius.circular(6),
  2111. ),
  2112. padding: const EdgeInsets.all(4),
  2113. child: inviteUrl != null
  2114. ? QrImageView(
  2115. data: inviteUrl!,
  2116. version: QrVersions.auto,
  2117. size: 80,
  2118. eyeStyle: QrEyeStyle(
  2119. eyeShape: QrEyeShape.square,
  2120. color: qrFgColor,
  2121. ),
  2122. dataModuleStyle: QrDataModuleStyle(
  2123. dataModuleShape: QrDataModuleShape.square,
  2124. color: qrFgColor,
  2125. ),
  2126. backgroundColor: qrBgColor,
  2127. errorCorrectionLevel: QrErrorCorrectLevel.M,
  2128. )
  2129. : const SizedBox(width: 80, height: 80),
  2130. ),
  2131. ],
  2132. ),
  2133. ],
  2134. ),
  2135. ),
  2136. );
  2137. }
  2138. }