my_copy_trading_screen.dart 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783
  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/network/dio_client.dart';
  15. import '../../../core/theme/app_colors.dart';
  16. import '../../../core/utils/dialog_utils.dart' show showConfirmDialog, showTipDialog, extractErrorMessage;
  17. import '../../../core/utils/number_format.dart';
  18. import '../../../core/utils/top_toast.dart';
  19. import '../../widgets/common/app_refresh_indicator.dart';
  20. import '../../widgets/common/app_shimmer.dart';
  21. import '../../widgets/common/app_tab_bar.dart';
  22. import '../../../data/models/copy_trading/copy_position.dart';
  23. import '../../../data/models/copy_trading/trader.dart';
  24. import '../../../data/repositories/copy_trading_repository.dart';
  25. import '../../../data/services/auth_service.dart';
  26. import '../../../providers/my_copy_trading_provider.dart';
  27. class MyCopyTradingScreen extends ConsumerStatefulWidget {
  28. const MyCopyTradingScreen({super.key});
  29. @override
  30. ConsumerState<MyCopyTradingScreen> createState() => _MyCopyTradingScreenState();
  31. }
  32. class _MyCopyTradingScreenState extends ConsumerState<MyCopyTradingScreen>
  33. with SingleTickerProviderStateMixin {
  34. late TabController _tabController;
  35. late PageController _pageController;
  36. final _currentScrollCtrl = ScrollController();
  37. final _tradersScrollCtrl = ScrollController();
  38. final _historyScrollCtrl = ScrollController();
  39. @override
  40. void initState() {
  41. super.initState();
  42. _tabController = TabController(length: 3, vsync: this);
  43. _pageController = PageController();
  44. // 每次进入页面都重置 tab 并刷新数据
  45. WidgetsBinding.instance.addPostFrameCallback((_) {
  46. ref.read(myCopyTradingProvider.notifier).setTab(0);
  47. ref.read(myCopyTradingProvider.notifier).refresh();
  48. });
  49. _tabController.addListener(() {
  50. if (!mounted) return;
  51. if (_tabController.indexIsChanging) {
  52. _pageController.animateToPage(
  53. _tabController.index,
  54. duration: const Duration(milliseconds: 280),
  55. curve: Curves.easeOut,
  56. );
  57. } else {
  58. ref.read(myCopyTradingProvider.notifier).setTab(_tabController.index);
  59. }
  60. });
  61. _pageController.addListener(() {
  62. if (!mounted) return;
  63. if (!_pageController.hasClients) return;
  64. final page = _pageController.page!;
  65. final offset = page - _tabController.index;
  66. if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
  67. _tabController.offset = offset.clamp(-1.0, 1.0);
  68. }
  69. });
  70. _currentScrollCtrl.addListener(() {
  71. if (!mounted) return;
  72. if (_currentScrollCtrl.position.pixels >= _currentScrollCtrl.position.maxScrollExtent - 200) {
  73. ref.read(myCopyTradingProvider.notifier).loadMoreCurrent();
  74. }
  75. });
  76. _tradersScrollCtrl.addListener(() {
  77. if (!mounted) return;
  78. if (_tradersScrollCtrl.position.pixels >= _tradersScrollCtrl.position.maxScrollExtent - 200) {
  79. ref.read(myCopyTradingProvider.notifier).loadMoreTraders();
  80. }
  81. });
  82. _historyScrollCtrl.addListener(() {
  83. if (!mounted) return;
  84. if (_historyScrollCtrl.position.pixels >= _historyScrollCtrl.position.maxScrollExtent - 200) {
  85. ref.read(myCopyTradingProvider.notifier).loadMoreHistory();
  86. }
  87. });
  88. }
  89. @override
  90. void dispose() {
  91. _tabController.dispose();
  92. _pageController.dispose();
  93. _currentScrollCtrl.dispose();
  94. _tradersScrollCtrl.dispose();
  95. _historyScrollCtrl.dispose();
  96. super.dispose();
  97. }
  98. @override
  99. Widget build(BuildContext context) {
  100. final cs = Theme.of(context).colorScheme;
  101. final isDark = Theme.of(context).brightness == Brightness.dark;
  102. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  103. final state = ref.watch(myCopyTradingProvider);
  104. final currentCount = state.currentPositions.length;
  105. final traderCount = state.myTraders.length;
  106. return Scaffold(
  107. backgroundColor: cardBg,
  108. appBar: AppBar(
  109. leading: IconButton(
  110. icon: const Icon(Icons.arrow_back_ios, size: 18),
  111. onPressed: () => context.pop(),
  112. ),
  113. title: Text(AppLocalizations.of(context)!.myCopyTrading, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
  114. ),
  115. body: state.isLoading
  116. ? _MyCopyTradingFullSkeleton(cardBg: cardBg)
  117. : Column(
  118. children: [
  119. // 统计卡片(带眼睛隐藏)—— 铺满白色背景
  120. if (state.account != null)
  121. _StatsCard(
  122. account: state.account!,
  123. onTransfer: () async {
  124. await context.push('/asset/transfer?from=SPOT&to=FOLLOW');
  125. if (context.mounted) {
  126. ref.read(myCopyTradingProvider.notifier).refresh();
  127. }
  128. },
  129. ),
  130. // Tab
  131. Container(
  132. decoration: BoxDecoration(
  133. border: Border(
  134. bottom: BorderSide(color: cs.outlineVariant.withAlpha(60), width: 1),
  135. ),
  136. ),
  137. child: TabBar(
  138. controller: _tabController,
  139. indicator: StretchTabIndicator(
  140. controller: _tabController,
  141. color: AppColors.brand,
  142. ),
  143. indicatorSize: TabBarIndicatorSize.tab,
  144. dividerColor: Colors.transparent,
  145. labelColor: AppColors.brand,
  146. unselectedLabelColor: cs.onSurface.withAlpha(153),
  147. labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
  148. unselectedLabelStyle: const TextStyle(fontSize: 14),
  149. tabs: [
  150. Tab(text: currentCount > 0 ? '${AppLocalizations.of(context)!.currentFollowOrders}($currentCount)' : AppLocalizations.of(context)!.currentFollowOrders),
  151. Tab(text: traderCount > 0 ? '${AppLocalizations.of(context)!.myTraders}($traderCount)' : AppLocalizations.of(context)!.myTraders),
  152. Tab(text: AppLocalizations.of(context)!.historyFollowOrders),
  153. ],
  154. ),
  155. ),
  156. // 内容区
  157. Expanded(
  158. child: PageView(
  159. controller: _pageController,
  160. physics: const BouncingScrollPhysics(
  161. parent: AlwaysScrollableScrollPhysics(),
  162. ),
  163. onPageChanged: (index) {
  164. if (_tabController.indexIsChanging) return;
  165. _tabController.index = index;
  166. },
  167. children: [
  168. _buildCurrentTab(state),
  169. _buildTradersTab(state),
  170. _buildHistoryTab(state),
  171. ],
  172. ),
  173. ),
  174. ],
  175. ),
  176. );
  177. }
  178. Widget _buildCurrentTab(MyCopyTradingState state) {
  179. final onRefresh = () => ref.read(myCopyTradingProvider.notifier).silentRefresh();
  180. if (state.isLoading && state.currentPositions.isEmpty) {
  181. return ListView.builder(
  182. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  183. itemCount: 3,
  184. itemBuilder: (_, __) => const _PositionCardSkeleton(),
  185. );
  186. }
  187. return AppRefreshIndicator(
  188. onRefresh: onRefresh,
  189. child: state.currentPositions.isEmpty
  190. ? ListView(
  191. physics: const AlwaysScrollableScrollPhysics(),
  192. children: [_EmptyState(onGoMarket: () => context.pop())],
  193. )
  194. : ListView.builder(
  195. controller: _currentScrollCtrl,
  196. physics: const AlwaysScrollableScrollPhysics(),
  197. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  198. itemCount: state.currentPositions.length + 1,
  199. itemBuilder: (_, i) {
  200. if (i >= state.currentPositions.length) {
  201. return _loadMoreFooter(state.currentLoadingMore, state.currentHasMore);
  202. }
  203. return _PositionCard(
  204. position: state.currentPositions[i],
  205. isHistory: false,
  206. onClose: (id) async {
  207. final confirmed = await showConfirmDialog(
  208. context,
  209. content: AppLocalizations.of(context)!.closePositionConfirmMsg,
  210. );
  211. if (!confirmed || !context.mounted) return;
  212. try {
  213. await ref.read(myCopyTradingProvider.notifier).closePosition(id);
  214. if (context.mounted) {
  215. showTopToast(context, message: AppLocalizations.of(context)!.closePositionSuccess, backgroundColor: AppColors.rise);
  216. }
  217. } catch (e) {
  218. if (context.mounted) {
  219. showTopToast(context, message: extractErrorMessage(e), backgroundColor: AppColors.fall);
  220. }
  221. }
  222. },
  223. );
  224. },
  225. ),
  226. );
  227. }
  228. Widget _buildTradersTab(MyCopyTradingState state) {
  229. final onRefresh = () => ref.read(myCopyTradingProvider.notifier).silentRefresh();
  230. if (state.isLoading && state.myTraders.isEmpty) {
  231. return ListView.builder(
  232. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  233. itemCount: 3,
  234. itemBuilder: (_, __) => const _MyTraderCardSkeleton(),
  235. );
  236. }
  237. return AppRefreshIndicator(
  238. onRefresh: onRefresh,
  239. child: state.myTraders.isEmpty
  240. ? ListView(
  241. physics: const AlwaysScrollableScrollPhysics(),
  242. children: [_EmptyState(onGoMarket: () => context.pop())],
  243. )
  244. : ListView.builder(
  245. controller: _tradersScrollCtrl,
  246. physics: const AlwaysScrollableScrollPhysics(),
  247. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  248. itemCount: state.myTraders.length + 1,
  249. itemBuilder: (_, i) {
  250. if (i >= state.myTraders.length) {
  251. return _loadMoreFooter(state.tradersLoadingMore, state.tradersHasMore);
  252. }
  253. return _MyTraderCard(
  254. trader: state.myTraders[i],
  255. onUnfollow: () async {
  256. final confirmed = await showConfirmDialog(
  257. context,
  258. content: AppLocalizations.of(context)!.unfollowTraderConfirm,
  259. );
  260. if (!confirmed || !context.mounted) return;
  261. try {
  262. await ref.read(myCopyTradingProvider.notifier).unfollowTrader(state.myTraders[i].id);
  263. } catch (e) {
  264. if (context.mounted) showTipDialog(context, content: extractErrorMessage(e));
  265. }
  266. },
  267. );
  268. },
  269. ),
  270. );
  271. }
  272. Widget _buildHistoryTab(MyCopyTradingState state) {
  273. final onRefresh = () => ref.read(myCopyTradingProvider.notifier).silentRefresh();
  274. if (state.isLoading && state.historyPositions.isEmpty) {
  275. return ListView.builder(
  276. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  277. itemCount: 3,
  278. itemBuilder: (_, __) => const _PositionCardSkeleton(),
  279. );
  280. }
  281. return AppRefreshIndicator(
  282. onRefresh: onRefresh,
  283. child: state.historyPositions.isEmpty
  284. ? ListView(
  285. physics: const AlwaysScrollableScrollPhysics(),
  286. children: [_EmptyState(onGoMarket: () => context.pop())],
  287. )
  288. : ListView.builder(
  289. controller: _historyScrollCtrl,
  290. physics: const AlwaysScrollableScrollPhysics(),
  291. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  292. itemCount: state.historyPositions.length + 1,
  293. itemBuilder: (_, i) {
  294. if (i >= state.historyPositions.length) {
  295. return _loadMoreFooter(state.historyLoadingMore, state.historyHasMore);
  296. }
  297. return _PositionCard(
  298. position: state.historyPositions[i],
  299. isHistory: true,
  300. );
  301. },
  302. ),
  303. );
  304. }
  305. Widget _loadMoreFooter(bool loading, bool hasMore) {
  306. if (loading) {
  307. return const Padding(
  308. padding: EdgeInsets.symmetric(vertical: 16),
  309. child: Center(child: CircularProgressIndicator(color: AppColors.brand, strokeWidth: 2)),
  310. );
  311. }
  312. if (!hasMore) {
  313. return Padding(
  314. padding: const EdgeInsets.symmetric(vertical: 16),
  315. child: Center(
  316. child: Text(AppLocalizations.of(context)!.noMore, style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(100), fontSize: 12)),
  317. ),
  318. );
  319. }
  320. return const SizedBox(height: 8);
  321. }
  322. }
  323. // ── 统计卡片(眼睛隐藏) ───────────────────────────────────
  324. class _StatsCard extends StatefulWidget {
  325. const _StatsCard({required this.account, this.onTransfer});
  326. final dynamic account;
  327. final VoidCallback? onTransfer;
  328. @override
  329. State<_StatsCard> createState() => _StatsCardState();
  330. }
  331. class _StatsCardState extends State<_StatsCard> {
  332. bool _visible = true;
  333. /// 截断到2位小数,不四舍五入(等同 Java RoundingMode.DOWN)
  334. String _fmtDown(double v) {
  335. final isNeg = v < 0;
  336. final abs = v.abs();
  337. final str = abs.toStringAsFixed(10);
  338. final dotIdx = str.indexOf('.');
  339. final intPart = dotIdx < 0 ? str : str.substring(0, dotIdx);
  340. final fracPart = dotIdx < 0 ? '00' : (str.substring(dotIdx + 1) + '00').substring(0, 2);
  341. return '${isNeg ? '-' : ''}$intPart.$fracPart';
  342. }
  343. @override
  344. Widget build(BuildContext context) {
  345. final acc = widget.account;
  346. final isDark = Theme.of(context).brightness == Brightness.dark;
  347. final cs = Theme.of(context).colorScheme;
  348. final pnlValue = acc.unrealizedPnl as double;
  349. // 黄色背景上用深绿色以保证对比度
  350. final pnlColor = pnlValue >= 0 ? const Color(0xFF1A7A4A) : AppColors.fall;
  351. final pnlSign = pnlValue >= 0 ? '+' : '';
  352. const iconColor = Colors.black54;
  353. return Container(
  354. margin: const EdgeInsets.fromLTRB(16, 12, 16, 4),
  355. decoration: BoxDecoration(
  356. color: AppColors.brand,
  357. borderRadius: BorderRadius.circular(16),
  358. ),
  359. child: Padding(
  360. padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
  361. child: Column(
  362. crossAxisAlignment: CrossAxisAlignment.start,
  363. children: [
  364. // 眼睛 + 划转
  365. Row(
  366. mainAxisAlignment: MainAxisAlignment.end,
  367. children: [
  368. GestureDetector(
  369. onTap: () => setState(() => _visible = !_visible),
  370. child: Icon(
  371. _visible ? Icons.visibility_outlined : Icons.visibility_off_outlined,
  372. size: 18,
  373. color: iconColor,
  374. ),
  375. ),
  376. const SizedBox(width: 12),
  377. GestureDetector(
  378. onTap: widget.onTransfer ?? () => context.push('/asset/transfer?from=SPOT&to=FOLLOW'),
  379. child: const Icon(Icons.sync_alt, size: 18, color: iconColor),
  380. ),
  381. ],
  382. ),
  383. const SizedBox(height: 8),
  384. // 三列数据
  385. Builder(builder: (context) {
  386. final l10n = AppLocalizations.of(context)!;
  387. return Row(
  388. children: [
  389. _StatCol(
  390. label: l10n.cumCopyProfitUsdt,
  391. value: _visible ? _fmtDown(acc.cumulativePnl as double) : '****',
  392. ),
  393. _StatCol(
  394. label: l10n.availableBalanceUsdt,
  395. value: _visible ? _fmtDown(acc.availableBalance as double) : '****',
  396. ),
  397. _StatCol(
  398. label: l10n.unrealizedPnlUsdt,
  399. value: _visible
  400. ? '$pnlSign${_fmtDown(pnlValue.abs())}'
  401. : '****',
  402. valueColor: _visible ? pnlColor : null,
  403. ),
  404. ],
  405. );
  406. }),
  407. ],
  408. ),
  409. ),
  410. );
  411. }
  412. }
  413. class _StatCol extends StatelessWidget {
  414. const _StatCol({required this.label, required this.value, this.valueColor});
  415. final String label;
  416. final String value;
  417. final Color? valueColor;
  418. @override
  419. Widget build(BuildContext context) {
  420. return Expanded(
  421. child: Column(
  422. crossAxisAlignment: CrossAxisAlignment.start,
  423. children: [
  424. Text(
  425. label,
  426. style: TextStyle(color: Colors.black.withAlpha(140), fontSize: 11),
  427. maxLines: 1,
  428. overflow: TextOverflow.ellipsis,
  429. ),
  430. const SizedBox(height: 5),
  431. Text(
  432. value,
  433. style: TextStyle(
  434. color: valueColor ?? Colors.black,
  435. fontSize: 16,
  436. fontWeight: FontWeight.w700,
  437. ),
  438. maxLines: 1,
  439. overflow: TextOverflow.ellipsis,
  440. ),
  441. ],
  442. ),
  443. );
  444. }
  445. }
  446. // ── 仓位卡片 ────────────────────────────────────────────────
  447. class _PositionCard extends StatelessWidget {
  448. const _PositionCard({
  449. required this.position,
  450. required this.isHistory,
  451. this.onClose,
  452. });
  453. final CopyPosition position;
  454. final bool isHistory;
  455. final void Function(String positionId)? onClose;
  456. static const _avatarColors = [
  457. Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
  458. Color(0xFFf3ba2f), Color(0xFF2775ca),
  459. ];
  460. Color get _avatarBg =>
  461. _avatarColors[position.traderName.codeUnitAt(0) % _avatarColors.length];
  462. @override
  463. Widget build(BuildContext context) {
  464. final cs = Theme.of(context).colorScheme;
  465. final isDark = Theme.of(context).brightness == Brightness.dark;
  466. final l10n = AppLocalizations.of(context)!;
  467. final isLong = position.isLong;
  468. final directionColor = isLong ? AppColors.rise : AppColors.fall;
  469. final directionLabel = isLong ? l10n.openLongBullish : l10n.openShortBearish;
  470. final pnlColor = position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall;
  471. final pnlSign = position.unrealizedPnl >= 0 ? '+' : '';
  472. final roiSign = position.roi >= 0 ? '+' : '';
  473. return Container(
  474. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  475. padding: const EdgeInsets.all(16),
  476. decoration: BoxDecoration(
  477. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  478. borderRadius: BorderRadius.circular(16),
  479. boxShadow: [
  480. BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
  481. ],
  482. ),
  483. child: Column(
  484. crossAxisAlignment: CrossAxisAlignment.start,
  485. children: [
  486. if (isHistory)
  487. // 历史跟单:品种在左,交易员头像+名称在右,分享按钮最右
  488. Row(
  489. children: [
  490. Expanded(
  491. child: Text(
  492. position.symbol,
  493. style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
  494. ),
  495. ),
  496. _TraderAvatar(
  497. name: position.traderName,
  498. avatarUrl: position.traderAvatar,
  499. bgColor: _avatarBg,
  500. size: 32,
  501. ),
  502. const SizedBox(width: 8),
  503. Text(position.traderName,
  504. style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)),
  505. const SizedBox(width: 10),
  506. GestureDetector(
  507. onTap: () => _showShareSheet(context),
  508. child: Icon(Icons.share_outlined, size: 18, color: cs.onSurface.withAlpha(120)),
  509. ),
  510. ],
  511. )
  512. else
  513. // 当前跟单:交易员头像+名称在左,平仓按钮在右
  514. Row(
  515. children: [
  516. _TraderAvatar(
  517. name: position.traderName,
  518. avatarUrl: position.traderAvatar,
  519. bgColor: _avatarBg,
  520. size: 38,
  521. ),
  522. const SizedBox(width: 10),
  523. Expanded(
  524. child: Text(
  525. position.traderName,
  526. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600),
  527. ),
  528. ),
  529. ElevatedButton(
  530. onPressed: () => onClose?.call(position.id),
  531. style: ElevatedButton.styleFrom(
  532. backgroundColor: AppColors.brand,
  533. foregroundColor: Colors.black,
  534. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  535. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  536. minimumSize: Size.zero,
  537. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  538. elevation: 0,
  539. ),
  540. child: Text(l10n.closePosition, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
  541. ),
  542. ],
  543. ),
  544. const SizedBox(height: 10),
  545. // 品种行(当前跟单时显示)
  546. if (!isHistory)
  547. Wrap(
  548. spacing: 6,
  549. runSpacing: 6,
  550. crossAxisAlignment: WrapCrossAlignment.center,
  551. children: [
  552. Text(position.symbol,
  553. maxLines: 1,
  554. overflow: TextOverflow.ellipsis,
  555. style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)),
  556. _Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
  557. _Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  558. _Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  559. _Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
  560. ],
  561. )
  562. else
  563. // 历史跟单:direction + 永续 + positionType + leverage
  564. Wrap(
  565. spacing: 6,
  566. runSpacing: 6,
  567. crossAxisAlignment: WrapCrossAlignment.center,
  568. children: [
  569. _Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
  570. _Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  571. _Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  572. _Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
  573. ],
  574. ),
  575. const SizedBox(height: 10),
  576. if (!isHistory) ...[
  577. // 当前跟单数据行
  578. Row(
  579. children: [
  580. _DataCell(label: l10n.openAvgPriceUsdt, value: position.openPrice.toStringAsFixed(1)),
  581. _DataCell(label: l10n.currentPriceUsdt, value: position.currentPrice.toStringAsFixed(1)),
  582. _DataCell(label: l10n.currentMarginUsdt, value: position.margin.toStringAsFixed(2)),
  583. ],
  584. ),
  585. const SizedBox(height: 8),
  586. Row(
  587. children: [
  588. _DataCell(label: l10n.qtyWithCoin(_baseAsset(position.symbol)), value: position.quantity.toStringAsFixed(4)),
  589. _DataCell(label: l10n.returnRate, value: '$roiSign${position.roi.toStringAsFixed(2)}%', valueColor: pnlColor),
  590. _DataCell(label: l10n.profitUsdt, value: '$pnlSign${position.unrealizedPnl.toStringAsFixed(3)}', valueColor: pnlColor),
  591. ],
  592. ),
  593. ] else ...[
  594. // 历史跟单数据行:数量 / 收益 / 收益率(三列,最后列右对齐)
  595. Row(
  596. children: [
  597. Expanded(
  598. child: Column(
  599. crossAxisAlignment: CrossAxisAlignment.start,
  600. children: [
  601. Text(l10n.qtyWithCoin(_baseAsset(position.symbol)),
  602. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  603. const SizedBox(height: 4),
  604. Text(position.quantity.toStringAsFixed(4),
  605. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
  606. ],
  607. ),
  608. ),
  609. Expanded(
  610. child: Column(
  611. crossAxisAlignment: CrossAxisAlignment.start,
  612. children: [
  613. Text(l10n.profitUsdt,
  614. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  615. const SizedBox(height: 4),
  616. Text('$pnlSign${position.unrealizedPnl.toStringAsFixed(4)}',
  617. style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
  618. ],
  619. ),
  620. ),
  621. Expanded(
  622. child: Column(
  623. crossAxisAlignment: CrossAxisAlignment.end,
  624. children: [
  625. Text(l10n.returnRate,
  626. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  627. const SizedBox(height: 4),
  628. Text('$roiSign${position.roi.toStringAsFixed(2)}%',
  629. style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
  630. ],
  631. ),
  632. ),
  633. ],
  634. ),
  635. const SizedBox(height: 16),
  636. Divider(height: 1, thickness: 1, color: cs.outlineVariant.withAlpha(60)),
  637. const SizedBox(height: 12),
  638. // 底部:开仓均价(左) / 平仓均价(右对齐)
  639. Row(
  640. children: [
  641. Expanded(
  642. child: Column(
  643. crossAxisAlignment: CrossAxisAlignment.start,
  644. children: [
  645. Text(l10n.openAvgPriceUsdt,
  646. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  647. const SizedBox(height: 3),
  648. Text(position.openPrice.toStringAsFixed(1),
  649. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
  650. const SizedBox(height: 2),
  651. Text(_formatDate(position.openTime),
  652. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
  653. ],
  654. ),
  655. ),
  656. Column(
  657. crossAxisAlignment: CrossAxisAlignment.end,
  658. children: [
  659. Text(l10n.closeAvgPriceUsdt,
  660. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  661. const SizedBox(height: 3),
  662. Text(
  663. position.closePrice != null ? position.closePrice!.toStringAsFixed(1) : '--',
  664. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700),
  665. ),
  666. const SizedBox(height: 2),
  667. Text(
  668. position.closeTime != null ? _formatDate(position.closeTime!) : '--',
  669. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
  670. ),
  671. ],
  672. ),
  673. ],
  674. ),
  675. ],
  676. if (!isHistory) ...[
  677. const SizedBox(height: 10),
  678. Row(
  679. children: [
  680. Text(
  681. l10n.openTimeWithValue(_formatDate(position.openTime)),
  682. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11),
  683. ),
  684. const Spacer(),
  685. Text('${l10n.positionIdPrefix}${position.id}',
  686. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  687. const SizedBox(width: 4),
  688. GestureDetector(
  689. onTap: () {
  690. Clipboard.setData(ClipboardData(text: position.id));
  691. showTopToast(context, message: l10n.copyPositionIdSuccess, backgroundColor: AppColors.rise);
  692. },
  693. child: Icon(Icons.copy_outlined, size: 14, color: cs.onSurface.withAlpha(153)),
  694. ),
  695. ],
  696. ),
  697. ],
  698. ],
  699. ),
  700. );
  701. }
  702. void _showShareSheet(BuildContext context) {
  703. showModalBottomSheet(
  704. context: context,
  705. useRootNavigator: true,
  706. backgroundColor: Colors.transparent,
  707. isScrollControlled: true,
  708. builder: (_) => _FollowShareSheet(position: position),
  709. );
  710. }
  711. String _baseAsset(String symbol) {
  712. final s = symbol.replaceAll(' 永续', '');
  713. final parts = s.split('/');
  714. return parts.isNotEmpty ? parts[0] : s;
  715. }
  716. String _formatDate(DateTime dt) =>
  717. '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  718. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
  719. }
  720. // ── 历史跟单分享 BottomSheet ───────────────────────────────
  721. class _FollowShareSheet extends ConsumerStatefulWidget {
  722. const _FollowShareSheet({required this.position});
  723. final CopyPosition position;
  724. @override
  725. ConsumerState<_FollowShareSheet> createState() => _FollowShareSheetState();
  726. }
  727. class _FollowShareSheetState extends ConsumerState<_FollowShareSheet> {
  728. final _cardKey = GlobalKey();
  729. bool _sharing = false;
  730. bool _saving = false;
  731. String? _inviteCode;
  732. String? _inviteUrl;
  733. @override
  734. void initState() {
  735. super.initState();
  736. _loadInviteInfo();
  737. }
  738. Future<void> _loadInviteInfo() async {
  739. try {
  740. final dio = ref.read(dioClientProvider);
  741. final data = await AuthService(dio).getMyInfo();
  742. final prefix = data['promotionPrefix']?.toString() ?? '';
  743. final code = data['promotionCode']?.toString() ?? '';
  744. final url = (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null;
  745. if (mounted) {
  746. setState(() {
  747. _inviteCode = code.isNotEmpty ? code : null;
  748. _inviteUrl = url;
  749. });
  750. }
  751. } catch (_) {}
  752. }
  753. Future<Uint8List?> _renderCard() async {
  754. final boundary = _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
  755. if (boundary == null) return null;
  756. final image = await boundary.toImage(pixelRatio: 3.0);
  757. final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  758. return byteData?.buffer.asUint8List();
  759. }
  760. Future<void> _doSave(BuildContext context) async {
  761. setState(() => _saving = true);
  762. try {
  763. final bytes = await _renderCard();
  764. if (bytes == null) return;
  765. await Gal.requestAccess();
  766. await Gal.putImageBytes(
  767. bytes,
  768. name: 'follow_share_${DateTime.now().millisecondsSinceEpoch}',
  769. );
  770. if (!context.mounted) return;
  771. showTopToast(context,
  772. message: AppLocalizations.of(context)!.saveSuccess,
  773. backgroundColor: AppColors.rise);
  774. } on GalException catch (e) {
  775. if (!context.mounted) return;
  776. final l10n = AppLocalizations.of(context)!;
  777. if (e.type == GalExceptionType.accessDenied) {
  778. showTopToast(context, message: l10n.photoPermissionDenied, backgroundColor: AppColors.fall);
  779. } else {
  780. showTopToast(context, message: l10n.saveFailed, backgroundColor: AppColors.fall);
  781. }
  782. } catch (e) {
  783. if (context.mounted) {
  784. showTopToast(context,
  785. message: AppLocalizations.of(context)!.saveFailed,
  786. backgroundColor: AppColors.fall);
  787. }
  788. } finally {
  789. if (mounted) setState(() => _saving = false);
  790. }
  791. }
  792. Future<void> _doShare(BuildContext context) async {
  793. setState(() => _sharing = true);
  794. try {
  795. final bytes = await _renderCard();
  796. if (bytes == null) return;
  797. final tmpDir = await getTemporaryDirectory();
  798. final file = File('${tmpDir.path}/follow_share_${DateTime.now().millisecondsSinceEpoch}.png');
  799. await file.writeAsBytes(bytes);
  800. if (!context.mounted) return;
  801. Navigator.of(context).pop();
  802. await Share.shareXFiles(
  803. [XFile(file.path, mimeType: 'image/png')],
  804. subject: AppLocalizations.of(context)!.myCopyTradingProfit,
  805. );
  806. } catch (e) {
  807. if (context.mounted) {
  808. showTopToast(context, message: AppLocalizations.of(context)!.shareFailed, backgroundColor: AppColors.fall);
  809. }
  810. } finally {
  811. if (mounted) setState(() => _sharing = false);
  812. }
  813. }
  814. @override
  815. Widget build(BuildContext context) {
  816. final cs = Theme.of(context).colorScheme;
  817. final isDark = Theme.of(context).brightness == Brightness.dark;
  818. final p = widget.position;
  819. final pnlPositive = p.unrealizedPnl >= 0;
  820. final l10n = AppLocalizations.of(context)!;
  821. return Container(
  822. decoration: BoxDecoration(
  823. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  824. borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  825. ),
  826. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  827. child: Column(
  828. mainAxisSize: MainAxisSize.min,
  829. children: [
  830. // 拖拽指示条
  831. Container(
  832. width: 36,
  833. height: 4,
  834. decoration: BoxDecoration(
  835. color: cs.onSurface.withAlpha(60),
  836. borderRadius: BorderRadius.circular(2),
  837. ),
  838. ),
  839. const SizedBox(height: 16),
  840. // 分享卡片预览
  841. RepaintBoundary(
  842. key: _cardKey,
  843. child: _FollowShareCard(
  844. position: p,
  845. inviteCode: _inviteCode,
  846. inviteUrl: _inviteUrl,
  847. ),
  848. ),
  849. const SizedBox(height: 24),
  850. // 操作按钮行:取消 | 保存海报 | 分享
  851. Row(
  852. children: [
  853. Expanded(
  854. child: OutlinedButton(
  855. onPressed: () => Navigator.of(context).pop(),
  856. style: OutlinedButton.styleFrom(
  857. padding: const EdgeInsets.symmetric(vertical: 12),
  858. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  859. ),
  860. child: Text(l10n.cancelLabel, style: TextStyle(color: cs.onSurface, fontSize: 14)),
  861. ),
  862. ),
  863. const SizedBox(width: 8),
  864. Expanded(
  865. child: OutlinedButton(
  866. onPressed: _saving ? null : () => _doSave(context),
  867. style: OutlinedButton.styleFrom(
  868. padding: const EdgeInsets.symmetric(vertical: 12),
  869. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  870. ),
  871. child: _saving
  872. ? SizedBox(
  873. width: 16, height: 16,
  874. child: CircularProgressIndicator(strokeWidth: 2, color: cs.onSurface.withAlpha(153)),
  875. )
  876. : Text(l10n.savePoster, style: TextStyle(color: cs.onSurface, fontSize: 14)),
  877. ),
  878. ),
  879. const SizedBox(width: 8),
  880. Expanded(
  881. child: ElevatedButton(
  882. onPressed: _sharing ? null : () => _doShare(context),
  883. style: ElevatedButton.styleFrom(
  884. backgroundColor: pnlPositive ? AppColors.rise : AppColors.fall,
  885. padding: const EdgeInsets.symmetric(vertical: 12),
  886. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  887. elevation: 0,
  888. ),
  889. child: _sharing
  890. ? const SizedBox(
  891. width: 16, height: 16,
  892. child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
  893. )
  894. : Text(l10n.shareLabel,
  895. style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
  896. ),
  897. ),
  898. ],
  899. ),
  900. ],
  901. ),
  902. );
  903. }
  904. }
  905. // ── 跟单分享卡片内容 ─────────────────────────────────────────
  906. class _FollowShareCard extends StatelessWidget {
  907. const _FollowShareCard({
  908. required this.position,
  909. this.inviteCode,
  910. this.inviteUrl,
  911. });
  912. final CopyPosition position;
  913. final String? inviteCode;
  914. final String? inviteUrl;
  915. String _baseCoin(String sym) {
  916. if (sym.contains('/')) return sym.split('/').first;
  917. return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
  918. }
  919. String _fmtDate(DateTime dt) {
  920. final mo = dt.month.toString().padLeft(2, '0');
  921. final d = dt.day.toString().padLeft(2, '0');
  922. final h = dt.hour.toString().padLeft(2, '0');
  923. final mi = dt.minute.toString().padLeft(2, '0');
  924. final s = dt.second.toString().padLeft(2, '0');
  925. return '${dt.year}-$mo-$d $h:$mi:$s';
  926. }
  927. @override
  928. Widget build(BuildContext context) {
  929. final isDark = Theme.of(context).brightness == Brightness.dark;
  930. final l10n = AppLocalizations.of(context)!;
  931. final p = position;
  932. final isLong = p.isLong;
  933. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  934. final pnlPositive = p.unrealizedPnl >= 0;
  935. final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall;
  936. final coinSymbol = _baseCoin(p.symbol);
  937. final roiStr = '${pnlPositive ? '+' : ''}${formatAmount(p.roi)}%';
  938. final closePrice = p.closePrice;
  939. final closeTime = p.closeTime;
  940. // 主题色变量
  941. final bgColors = isDark
  942. ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)]
  943. : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)];
  944. final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E);
  945. final textSecondary = isDark
  946. ? Colors.white.withAlpha(120)
  947. : const Color(0xFF1A1F2E).withAlpha(120);
  948. final textMuted = isDark
  949. ? Colors.white.withAlpha(80)
  950. : const Color(0xFF1A1F2E).withAlpha(80);
  951. final borderColor = isDark
  952. ? Colors.white.withAlpha(40)
  953. : const Color(0xFF1A1F2E).withAlpha(30);
  954. final qrFgColor = isDark ? Colors.white : Colors.black;
  955. final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white;
  956. return Container(
  957. width: double.infinity,
  958. decoration: BoxDecoration(
  959. gradient: LinearGradient(
  960. begin: Alignment.topLeft,
  961. end: Alignment.bottomRight,
  962. colors: bgColors,
  963. ),
  964. borderRadius: BorderRadius.circular(16),
  965. ),
  966. clipBehavior: Clip.antiAlias,
  967. child: Padding(
  968. padding: const EdgeInsets.all(20),
  969. child: Column(
  970. crossAxisAlignment: CrossAxisAlignment.start,
  971. children: [
  972. // LOGO + 品牌名
  973. Row(
  974. children: [
  975. Image.asset(
  976. 'assets/images/app_icon.png',
  977. height: 28,
  978. width: 28,
  979. errorBuilder: (_, __, ___) => const SizedBox.shrink(),
  980. ),
  981. const SizedBox(width: 8),
  982. Text(
  983. 'iBit',
  984. style: TextStyle(
  985. color: textPrimary,
  986. fontSize: 14,
  987. fontWeight: FontWeight.w700,
  988. letterSpacing: 0.5),
  989. ),
  990. ],
  991. ),
  992. const SizedBox(height: 14),
  993. // 币对 + 永续 tag
  994. Row(
  995. children: [
  996. Text(
  997. '${coinSymbol}USDT',
  998. style: TextStyle(
  999. color: textPrimary,
  1000. fontSize: 22,
  1001. fontWeight: FontWeight.w800),
  1002. ),
  1003. const SizedBox(width: 8),
  1004. Container(
  1005. padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
  1006. decoration: BoxDecoration(
  1007. color: const Color(0xFFFFAB00),
  1008. borderRadius: BorderRadius.circular(4),
  1009. ),
  1010. child: Text(l10n.perpetual,
  1011. style: const TextStyle(
  1012. color: Colors.white,
  1013. fontSize: 11,
  1014. fontWeight: FontWeight.w700)),
  1015. ),
  1016. ],
  1017. ),
  1018. const SizedBox(height: 4),
  1019. // 方向 + 杠杆
  1020. Text(
  1021. '${isLong ? l10n.openLong : l10n.openShort} ${p.leverage}X',
  1022. style: TextStyle(
  1023. color: sideColor,
  1024. fontSize: 15,
  1025. fontWeight: FontWeight.w700),
  1026. ),
  1027. const SizedBox(height: 14),
  1028. // 收益率(大字)
  1029. Text(l10n.returnRate,
  1030. style: TextStyle(color: textSecondary, fontSize: 12)),
  1031. const SizedBox(height: 4),
  1032. Text(roiStr,
  1033. style: TextStyle(
  1034. color: pnlColor,
  1035. fontSize: 36,
  1036. fontWeight: FontWeight.w800,
  1037. letterSpacing: -0.5)),
  1038. const SizedBox(height: 16),
  1039. // 开仓均价 + 平仓均价
  1040. Row(
  1041. children: [
  1042. Expanded(
  1043. child: _FollowShareDataItem(
  1044. label: l10n.openAvgPrice,
  1045. value: formatAmount(p.openPrice),
  1046. textPrimary: textPrimary,
  1047. textSecondary: textSecondary,
  1048. ),
  1049. ),
  1050. Expanded(
  1051. child: _FollowShareDataItem(
  1052. label: l10n.avgClosePrice,
  1053. value: closePrice != null ? formatAmount(closePrice) : '--',
  1054. align: CrossAxisAlignment.end,
  1055. textPrimary: textPrimary,
  1056. textSecondary: textSecondary,
  1057. ),
  1058. ),
  1059. ],
  1060. ),
  1061. const SizedBox(height: 10),
  1062. // 时间
  1063. Text(closeTime != null ? _fmtDate(closeTime) : _fmtDate(p.openTime),
  1064. style: TextStyle(color: textMuted, fontSize: 11)),
  1065. const SizedBox(height: 14),
  1066. // 分隔线
  1067. Divider(color: borderColor, height: 1),
  1068. const SizedBox(height: 14),
  1069. // 邀请码 + 二维码
  1070. Row(
  1071. crossAxisAlignment: CrossAxisAlignment.center,
  1072. children: [
  1073. Expanded(
  1074. child: Column(
  1075. crossAxisAlignment: CrossAxisAlignment.start,
  1076. children: [
  1077. if (inviteCode != null)
  1078. RichText(
  1079. text: TextSpan(
  1080. style: const TextStyle(fontSize: 15),
  1081. children: [
  1082. TextSpan(
  1083. text: l10n.inviteCodeLabel,
  1084. style: TextStyle(color: textSecondary),
  1085. ),
  1086. TextSpan(
  1087. text: inviteCode!,
  1088. style: const TextStyle(
  1089. color: AppColors.brand,
  1090. fontWeight: FontWeight.w700),
  1091. ),
  1092. ],
  1093. ),
  1094. ),
  1095. const SizedBox(height: 4),
  1096. Text(l10n.registerAndEarnRebate,
  1097. style: TextStyle(color: textMuted, fontSize: 12)),
  1098. ],
  1099. ),
  1100. ),
  1101. Container(
  1102. decoration: BoxDecoration(
  1103. border: Border.all(color: borderColor, width: 1),
  1104. borderRadius: BorderRadius.circular(6),
  1105. ),
  1106. padding: const EdgeInsets.all(4),
  1107. child: inviteUrl != null
  1108. ? QrImageView(
  1109. data: inviteUrl!,
  1110. version: QrVersions.auto,
  1111. size: 80,
  1112. eyeStyle: QrEyeStyle(
  1113. eyeShape: QrEyeShape.square,
  1114. color: qrFgColor,
  1115. ),
  1116. dataModuleStyle: QrDataModuleStyle(
  1117. dataModuleShape: QrDataModuleShape.square,
  1118. color: qrFgColor,
  1119. ),
  1120. backgroundColor: qrBgColor,
  1121. errorCorrectionLevel: QrErrorCorrectLevel.M,
  1122. )
  1123. : const SizedBox(width: 80, height: 80),
  1124. ),
  1125. ],
  1126. ),
  1127. ],
  1128. ),
  1129. ),
  1130. );
  1131. }
  1132. }
  1133. class _FollowShareDataItem extends StatelessWidget {
  1134. const _FollowShareDataItem({
  1135. required this.label,
  1136. required this.value,
  1137. required this.textPrimary,
  1138. required this.textSecondary,
  1139. this.align = CrossAxisAlignment.start,
  1140. });
  1141. final String label;
  1142. final String value;
  1143. final Color textPrimary;
  1144. final Color textSecondary;
  1145. final CrossAxisAlignment align;
  1146. @override
  1147. Widget build(BuildContext context) {
  1148. return Column(
  1149. crossAxisAlignment: align,
  1150. children: [
  1151. Text(label, style: TextStyle(color: textSecondary, fontSize: 11)),
  1152. const SizedBox(height: 2),
  1153. Text(value,
  1154. style: TextStyle(
  1155. color: textPrimary, fontSize: 13, fontWeight: FontWeight.w600)),
  1156. ],
  1157. );
  1158. }
  1159. }
  1160. // ── 交易员头像 ────────────────────────────────────────────
  1161. class _TraderAvatar extends StatelessWidget {
  1162. const _TraderAvatar({
  1163. required this.name,
  1164. required this.bgColor,
  1165. required this.size,
  1166. this.avatarUrl,
  1167. });
  1168. final String name;
  1169. final String? avatarUrl;
  1170. final Color bgColor;
  1171. final double size;
  1172. @override
  1173. Widget build(BuildContext context) {
  1174. final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
  1175. if (avatarUrl != null && avatarUrl!.isNotEmpty) {
  1176. return ClipOval(
  1177. child: Image.network(
  1178. avatarUrl!,
  1179. width: size,
  1180. height: size,
  1181. fit: BoxFit.cover,
  1182. errorBuilder: (_, __, ___) => _fallback(letter),
  1183. ),
  1184. );
  1185. }
  1186. return _fallback(letter);
  1187. }
  1188. Widget _fallback(String letter) => Container(
  1189. width: size,
  1190. height: size,
  1191. decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle),
  1192. child: Center(
  1193. child: Text(letter,
  1194. style: TextStyle(
  1195. color: Colors.white,
  1196. fontSize: size * 0.42,
  1197. fontWeight: FontWeight.w700)),
  1198. ),
  1199. );
  1200. }
  1201. // ── 我的交易员卡片(原型风格) ─────────────────────────────
  1202. class _MyTraderCard extends StatelessWidget {
  1203. const _MyTraderCard({required this.trader, required this.onUnfollow});
  1204. final Trader trader;
  1205. final VoidCallback onUnfollow;
  1206. static const _avatarColors = [
  1207. Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
  1208. Color(0xFFf3ba2f), Color(0xFF2775ca),
  1209. ];
  1210. Color get _bg =>
  1211. _avatarColors[trader.avatarLetter.codeUnitAt(0) % _avatarColors.length];
  1212. @override
  1213. Widget build(BuildContext context) {
  1214. final cs = Theme.of(context).colorScheme;
  1215. final isDark = Theme.of(context).brightness == Brightness.dark;
  1216. return Container(
  1217. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1218. padding: const EdgeInsets.all(14),
  1219. decoration: BoxDecoration(
  1220. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1221. borderRadius: BorderRadius.circular(12),
  1222. boxShadow: [
  1223. BoxShadow(color: Colors.black.withAlpha(15), blurRadius: 8, offset: const Offset(0, 2)),
  1224. ],
  1225. ),
  1226. child: Column(
  1227. crossAxisAlignment: CrossAxisAlignment.start,
  1228. children: [
  1229. // 头部:头像 + 名称+描述 + 取消跟随按钮
  1230. Row(
  1231. crossAxisAlignment: CrossAxisAlignment.start,
  1232. children: [
  1233. // 头像 + 等级角标
  1234. SizedBox(
  1235. width: 52,
  1236. height: 60,
  1237. child: Stack(
  1238. clipBehavior: Clip.none,
  1239. children: [
  1240. _TraderAvatar(
  1241. name: trader.name,
  1242. avatarUrl: trader.avatarUrl,
  1243. bgColor: _bg,
  1244. size: 52,
  1245. ),
  1246. if (trader.levelName != null && trader.levelName!.isNotEmpty)
  1247. Positioned(
  1248. bottom: 0,
  1249. left: 0,
  1250. right: 0,
  1251. child: Center(
  1252. child: Container(
  1253. padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
  1254. decoration: BoxDecoration(
  1255. color: AppColors.darkBadgeBg,
  1256. borderRadius: BorderRadius.circular(20),
  1257. border: Border.all(color: AppColors.darkBgMid),
  1258. ),
  1259. child: Row(
  1260. mainAxisSize: MainAxisSize.min,
  1261. children: [
  1262. const Icon(Icons.link, size: 8, color: AppColors.rankPurple),
  1263. const SizedBox(width: 2),
  1264. Text(trader.levelName!,
  1265. style: const TextStyle(
  1266. color: AppColors.rankPurple,
  1267. fontSize: 9,
  1268. fontWeight: FontWeight.w700)),
  1269. ],
  1270. ),
  1271. ),
  1272. ),
  1273. ),
  1274. ],
  1275. ),
  1276. ),
  1277. const SizedBox(width: 12),
  1278. // 名称 + 描述
  1279. Expanded(
  1280. child: Column(
  1281. crossAxisAlignment: CrossAxisAlignment.start,
  1282. children: [
  1283. Text(trader.name,
  1284. style: TextStyle(
  1285. color: cs.onSurface,
  1286. fontSize: 15,
  1287. fontWeight: FontWeight.w600)),
  1288. if (trader.description != null && trader.description!.isNotEmpty) ...[
  1289. const SizedBox(height: 2),
  1290. Text(trader.description!,
  1291. style: TextStyle(color: cs.onSurface.withAlpha(130), fontSize: 12),
  1292. maxLines: 1,
  1293. overflow: TextOverflow.ellipsis),
  1294. ],
  1295. if (trader.tags.isNotEmpty) ...[
  1296. const SizedBox(height: 6),
  1297. Wrap(
  1298. spacing: 6,
  1299. runSpacing: 4,
  1300. children: trader.tags
  1301. .map((tag) => Container(
  1302. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
  1303. decoration: BoxDecoration(
  1304. color: isDark ? AppColors.tagIndigoBgDark : AppColors.tagIndigoBgLight,
  1305. borderRadius: BorderRadius.circular(20),
  1306. border: Border.all(color: AppColors.tagIndigo.withAlpha(80), width: 0.8),
  1307. ),
  1308. child: Text(tag,
  1309. style: const TextStyle(
  1310. color: AppColors.tagIndigo,
  1311. fontSize: 11,
  1312. fontWeight: FontWeight.w500)),
  1313. ))
  1314. .toList(),
  1315. ),
  1316. ],
  1317. ],
  1318. ),
  1319. ),
  1320. const SizedBox(width: 8),
  1321. // 取消跟随按钮
  1322. OutlinedButton(
  1323. onPressed: onUnfollow,
  1324. style: OutlinedButton.styleFrom(
  1325. backgroundColor: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1326. side: BorderSide(color: cs.onSurface, width: 1.5),
  1327. foregroundColor: cs.onSurface,
  1328. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
  1329. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
  1330. minimumSize: Size.zero,
  1331. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  1332. ),
  1333. child: Text(AppLocalizations.of(context)!.unfollow, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
  1334. ),
  1335. ],
  1336. ),
  1337. const SizedBox(height: 12),
  1338. Divider(height: 1, thickness: 0.5, color: cs.outlineVariant.withAlpha(80)),
  1339. const SizedBox(height: 12),
  1340. // 底部统计行(含竖向分隔线)
  1341. Builder(builder: (context) {
  1342. final l10n = AppLocalizations.of(context)!;
  1343. return IntrinsicHeight(
  1344. child: Row(
  1345. children: [
  1346. _TraderStatCol(
  1347. label: l10n.profitUsdt,
  1348. value: trader.profitAmount == 0
  1349. ? '--'
  1350. : '${trader.profitAmount >= 0 ? '+' : ''}${trader.profitAmount.toStringAsFixed(2)}',
  1351. valueColor: trader.profitAmount >= 0 ? AppColors.rise : AppColors.fall,
  1352. ),
  1353. VerticalDivider(width: 1, thickness: 0.8, color: Colors.grey.withAlpha(100)),
  1354. _TraderStatCol(
  1355. label: l10n.cumFollowerCount,
  1356. value: trader.followCustomer == 0 ? '--' : '${trader.followCustomer}',
  1357. center: true,
  1358. ),
  1359. VerticalDivider(width: 1, thickness: 0.8, color: Colors.grey.withAlpha(100)),
  1360. _TraderStatCol(
  1361. label: l10n.cumTradingDays,
  1362. value: trader.tradingDays == 0 ? '--' : '${trader.tradingDays}',
  1363. alignEnd: true,
  1364. ),
  1365. ],
  1366. ),
  1367. );
  1368. }),
  1369. ],
  1370. ),
  1371. );
  1372. }
  1373. }
  1374. class _TraderStatCol extends StatelessWidget {
  1375. const _TraderStatCol({
  1376. required this.label,
  1377. required this.value,
  1378. this.valueColor,
  1379. this.center = false,
  1380. this.alignEnd = false,
  1381. });
  1382. final String label;
  1383. final String value;
  1384. final Color? valueColor;
  1385. final bool center;
  1386. final bool alignEnd;
  1387. @override
  1388. Widget build(BuildContext context) {
  1389. final cs = Theme.of(context).colorScheme;
  1390. final align = alignEnd
  1391. ? CrossAxisAlignment.end
  1392. : center
  1393. ? CrossAxisAlignment.center
  1394. : CrossAxisAlignment.start;
  1395. return Expanded(
  1396. child: Column(
  1397. crossAxisAlignment: align,
  1398. children: [
  1399. Text(label, style: TextStyle(color: cs.onSurface.withAlpha(130), fontSize: 11)),
  1400. const SizedBox(height: 3),
  1401. Text(value,
  1402. style: TextStyle(
  1403. color: valueColor ?? cs.onSurface,
  1404. fontSize: 14,
  1405. fontWeight: FontWeight.w700)),
  1406. ],
  1407. ),
  1408. );
  1409. }
  1410. }
  1411. // ── Badge / DataCell ──────────────────────────────────────
  1412. class _Badge extends StatelessWidget {
  1413. const _Badge({required this.text, required this.bgColor, required this.textColor, this.borderColor});
  1414. final String text;
  1415. final Color bgColor;
  1416. final Color textColor;
  1417. final Color? borderColor;
  1418. @override
  1419. Widget build(BuildContext context) {
  1420. return Container(
  1421. padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
  1422. decoration: BoxDecoration(
  1423. color: bgColor,
  1424. borderRadius: BorderRadius.circular(4),
  1425. border: borderColor != null ? Border.all(color: borderColor!, width: 0.8) : null,
  1426. ),
  1427. child: Text(text, style: TextStyle(color: textColor, fontSize: 11, fontWeight: FontWeight.w500)),
  1428. );
  1429. }
  1430. }
  1431. class _DataCell extends StatelessWidget {
  1432. const _DataCell({required this.label, required this.value, this.valueColor});
  1433. final String label;
  1434. final String value;
  1435. final Color? valueColor;
  1436. @override
  1437. Widget build(BuildContext context) {
  1438. final cs = Theme.of(context).colorScheme;
  1439. return Expanded(
  1440. child: Column(
  1441. crossAxisAlignment: CrossAxisAlignment.start,
  1442. children: [
  1443. Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1444. const SizedBox(height: 2),
  1445. Text(value,
  1446. style: TextStyle(
  1447. color: valueColor ?? cs.onSurface,
  1448. fontSize: 13,
  1449. fontWeight: FontWeight.w500)),
  1450. ],
  1451. ),
  1452. );
  1453. }
  1454. }
  1455. // ── 骨架屏 ────────────────────────────────────────────────
  1456. /// 「我的跟单」首次加载时的全页骨架
  1457. class _MyCopyTradingFullSkeleton extends StatelessWidget {
  1458. const _MyCopyTradingFullSkeleton({required this.cardBg});
  1459. final Color cardBg;
  1460. @override
  1461. Widget build(BuildContext context) {
  1462. final isDark = Theme.of(context).brightness == Brightness.dark;
  1463. final cs = Theme.of(context).colorScheme;
  1464. return Column(
  1465. children: [
  1466. // 统计卡片骨架
  1467. AppShimmer(
  1468. child: Container(
  1469. margin: const EdgeInsets.fromLTRB(16, 12, 16, 4),
  1470. padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
  1471. decoration: BoxDecoration(
  1472. color: AppColors.brand,
  1473. borderRadius: BorderRadius.circular(16),
  1474. ),
  1475. child: Column(
  1476. crossAxisAlignment: CrossAxisAlignment.start,
  1477. children: [
  1478. Row(
  1479. mainAxisAlignment: MainAxisAlignment.end,
  1480. children: [shimmerBox(18, 18), const SizedBox(width: 10), shimmerBox(18, 18)],
  1481. ),
  1482. const SizedBox(height: 8),
  1483. Row(
  1484. children: List.generate(3, (i) => Expanded(
  1485. child: Padding(
  1486. padding: EdgeInsets.only(right: i < 2 ? 12 : 0),
  1487. child: Column(
  1488. crossAxisAlignment: CrossAxisAlignment.start,
  1489. children: [
  1490. shimmerBox(70, 10),
  1491. const SizedBox(height: 6),
  1492. shimmerBox(55, 16),
  1493. ],
  1494. ),
  1495. ),
  1496. )),
  1497. ),
  1498. ],
  1499. ),
  1500. ),
  1501. ),
  1502. // Tab 骨架
  1503. Container(
  1504. color: cardBg,
  1505. child: AppShimmer(
  1506. child: Padding(
  1507. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  1508. child: Row(
  1509. children: List.generate(3, (i) => Expanded(
  1510. child: Padding(
  1511. padding: EdgeInsets.symmetric(horizontal: i == 1 ? 8.0 : 0),
  1512. child: shimmerFill(16, radius: 4),
  1513. ),
  1514. )),
  1515. ),
  1516. ),
  1517. ),
  1518. ),
  1519. Divider(height: 1, thickness: 0.5, color: cs.outlineVariant.withAlpha(80)),
  1520. // 列表骨架
  1521. Expanded(
  1522. child: ListView.builder(
  1523. padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
  1524. itemCount: 4,
  1525. itemBuilder: (_, __) => const _PositionCardSkeleton(),
  1526. ),
  1527. ),
  1528. ],
  1529. );
  1530. }
  1531. }
  1532. /// 仓位卡片骨架(对应 _PositionCard 当前跟单样式)
  1533. class _PositionCardSkeleton extends StatelessWidget {
  1534. const _PositionCardSkeleton();
  1535. @override
  1536. Widget build(BuildContext context) {
  1537. final isDark = Theme.of(context).brightness == Brightness.dark;
  1538. return AppShimmer(
  1539. child: Container(
  1540. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1541. padding: const EdgeInsets.all(16),
  1542. decoration: BoxDecoration(
  1543. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1544. borderRadius: BorderRadius.circular(16),
  1545. boxShadow: [
  1546. BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
  1547. ],
  1548. ),
  1549. child: Column(
  1550. crossAxisAlignment: CrossAxisAlignment.start,
  1551. children: [
  1552. // 交易员头部行
  1553. Row(
  1554. children: [
  1555. shimmerCircle(38),
  1556. const SizedBox(width: 10),
  1557. Expanded(child: shimmerBox(100, 15)),
  1558. shimmerBox(60, 32, radius: 8),
  1559. ],
  1560. ),
  1561. const SizedBox(height: 10),
  1562. // 品种行
  1563. Row(children: [
  1564. shimmerBox(80, 14),
  1565. const SizedBox(width: 8),
  1566. shimmerBox(60, 20, radius: 4),
  1567. const SizedBox(width: 6),
  1568. shimmerBox(30, 20, radius: 4),
  1569. const SizedBox(width: 6),
  1570. shimmerBox(36, 20, radius: 4),
  1571. ]),
  1572. const SizedBox(height: 10),
  1573. // 数据行 1
  1574. Row(children: List.generate(3, (i) => Expanded(
  1575. child: Padding(
  1576. padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
  1577. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  1578. shimmerBox(70, 11),
  1579. const SizedBox(height: 4),
  1580. shimmerBox(55, 13),
  1581. ]),
  1582. ),
  1583. ))),
  1584. const SizedBox(height: 8),
  1585. // 数据行 2
  1586. Row(children: List.generate(3, (i) => Expanded(
  1587. child: Padding(
  1588. padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
  1589. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  1590. shimmerBox(70, 11),
  1591. const SizedBox(height: 4),
  1592. shimmerBox(55, 13),
  1593. ]),
  1594. ),
  1595. ))),
  1596. ],
  1597. ),
  1598. ),
  1599. );
  1600. }
  1601. }
  1602. /// 我的交易员卡片骨架(对应 _MyTraderCard 样式)
  1603. class _MyTraderCardSkeleton extends StatelessWidget {
  1604. const _MyTraderCardSkeleton();
  1605. @override
  1606. Widget build(BuildContext context) {
  1607. final isDark = Theme.of(context).brightness == Brightness.dark;
  1608. final cs = Theme.of(context).colorScheme;
  1609. return AppShimmer(
  1610. child: Container(
  1611. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  1612. padding: const EdgeInsets.all(14),
  1613. decoration: BoxDecoration(
  1614. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  1615. borderRadius: BorderRadius.circular(12),
  1616. boxShadow: [
  1617. BoxShadow(color: Colors.black.withAlpha(15), blurRadius: 8, offset: const Offset(0, 2)),
  1618. ],
  1619. ),
  1620. child: Column(
  1621. crossAxisAlignment: CrossAxisAlignment.start,
  1622. children: [
  1623. Row(
  1624. crossAxisAlignment: CrossAxisAlignment.start,
  1625. children: [
  1626. shimmerCircle(52),
  1627. const SizedBox(width: 12),
  1628. Expanded(
  1629. child: Column(
  1630. crossAxisAlignment: CrossAxisAlignment.start,
  1631. children: [
  1632. shimmerBox(110, 15),
  1633. const SizedBox(height: 6),
  1634. shimmerBox(160, 12),
  1635. const SizedBox(height: 8),
  1636. Row(children: [shimmerBox(55, 22, radius: 20), const SizedBox(width: 6), shimmerBox(45, 22, radius: 20)]),
  1637. ],
  1638. ),
  1639. ),
  1640. shimmerBox(72, 30, radius: 20),
  1641. ],
  1642. ),
  1643. const SizedBox(height: 12),
  1644. Divider(height: 1, thickness: 0.5, color: cs.outlineVariant.withAlpha(80)),
  1645. const SizedBox(height: 12),
  1646. Row(
  1647. children: List.generate(3, (i) => Expanded(
  1648. child: Padding(
  1649. padding: EdgeInsets.symmetric(horizontal: i == 1 ? 8.0 : 0),
  1650. child: Column(
  1651. crossAxisAlignment: i == 0
  1652. ? CrossAxisAlignment.start
  1653. : i == 1 ? CrossAxisAlignment.center : CrossAxisAlignment.end,
  1654. children: [
  1655. shimmerBox(55, 11),
  1656. const SizedBox(height: 5),
  1657. shimmerBox(40, 14),
  1658. ],
  1659. ),
  1660. ),
  1661. )),
  1662. ),
  1663. ],
  1664. ),
  1665. ),
  1666. );
  1667. }
  1668. }
  1669. // ── 空状态 ────────────────────────────────────────────────
  1670. class _EmptyState extends StatelessWidget {
  1671. const _EmptyState({this.onGoMarket});
  1672. final VoidCallback? onGoMarket;
  1673. @override
  1674. Widget build(BuildContext context) {
  1675. final cs = Theme.of(context).colorScheme;
  1676. return Center(
  1677. child: Column(
  1678. mainAxisSize: MainAxisSize.min,
  1679. children: [
  1680. Icon(Icons.people_alt_outlined, size: 64, color: cs.onSurface.withAlpha(80)),
  1681. const SizedBox(height: 16),
  1682. Text(AppLocalizations.of(context)!.noData, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 15)),
  1683. ],
  1684. ),
  1685. );
  1686. }
  1687. }