| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677 |
- import 'dart:io';
- import 'dart:typed_data';
- import 'dart:ui' as ui;
- import 'package:flutter/material.dart';
- import 'package:flutter/rendering.dart';
- import 'package:flutter/services.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:gal/gal.dart';
- import 'package:go_router/go_router.dart';
- import 'package:path_provider/path_provider.dart';
- import 'package:qr_flutter/qr_flutter.dart';
- import 'package:share_plus/share_plus.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/utils/avatar_urls.dart';
- import '../../../core/network/dio_client.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/dialog_utils.dart' show extractErrorMessage;
- import '../../../core/utils/top_toast.dart';
- import '../../../data/repositories/copy_trading_repository.dart';
- import '../../../data/services/auth_service.dart';
- import '../../../providers/app_provider.dart';
- import '../../widgets/common/app_refresh_indicator.dart';
- import '../../widgets/common/app_shimmer.dart';
- import '../../widgets/common/app_tab_bar.dart';
- class MyTradesScreen extends ConsumerStatefulWidget {
- const MyTradesScreen({super.key});
- @override
- ConsumerState<MyTradesScreen> createState() => _MyTradesScreenState();
- }
- class _MyTradesScreenState extends ConsumerState<MyTradesScreen>
- with SingleTickerProviderStateMixin {
- late TabController _tabController;
- late PageController _pageController;
- bool _loadingProfile = true;
- Map<String, dynamic>? _traderInfo;
- List<Map<String, dynamic>> _tags = [];
- int? _followerCount;
- bool _loadingFollowers = true;
- bool _followersLoaded = false;
- List<Map<String, dynamic>> _followers = [];
- int _followersPage = 1;
- bool _followersHasMore = true;
- bool _followersLoadingMore = false;
- static const _followersPageSize = 20;
- bool _loadingCurrentOrders = true;
- bool _currentOrdersLoaded = false;
- List<Map<String, dynamic>> _currentOrders = [];
- bool _loadingHistoryOrders = true;
- bool _historyOrdersLoaded = false;
- List<Map<String, dynamic>> _historyOrders = [];
- String _traderId = '';
- List<String> _traderSymbols = [];
- bool _symbolExpanded = true;
- @override
- void initState() {
- super.initState();
- _tabController = TabController(length: 3, vsync: this);
- _pageController = PageController();
- _tabController.addListener(() {
- if (!context.mounted) return;
- if (_tabController.indexIsChanging) {
- _pageController.animateToPage(
- _tabController.index,
- duration: const Duration(milliseconds: 280),
- curve: Curves.easeOut,
- );
- } else {
- _onTabChanged(_tabController.index);
- }
- });
- _pageController.addListener(() {
- if (!context.mounted) return;
- if (!_pageController.hasClients) return;
- final page = _pageController.page!;
- final offset = page - _tabController.index;
- if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
- _tabController.offset = offset.clamp(-1.0, 1.0);
- }
- });
- _loadProfile();
- }
- @override
- void dispose() {
- _tabController.dispose();
- _pageController.dispose();
- super.dispose();
- }
- Future<void> _loadProfile() async {
- if (!context.mounted) return;
- setState(() => _loadingProfile = true);
- try {
- final repo = ref.read(copyTradingRepositoryProvider);
- // Step1: traderId 是后续请求的依赖,先串行获取
- final followerInfo = await repo.getFollowerInfo();
- _traderId = followerInfo?['id']?.toString() ?? '';
- // Step2: 全部数据并行加载,skeleton 保持到所有数据就绪
- final results = await Future.wait([
- _traderId.isNotEmpty // [0] 带单员信息
- ? repo.getTraderInfo(_traderId).then<dynamic>((v) => v)
- : Future<dynamic>.value(null),
- repo.getMyTags().then<dynamic>((v) => v), // [1] 标签
- repo
- .getMyFollowerCount() // [2] 跟单人数
- .then<dynamic>((v) => v)
- .catchError((_) => 0 as dynamic),
- repo
- .getMyFollowers(page: 1, pageSize: _followersPageSize) // [3] 跟单用户
- .then<dynamic>((v) => v),
- _traderId.isNotEmpty // [4] 当前带单 + 合约持仓
- ? Future.wait([
- repo.getTraderOrders(traderId: _traderId, type: 'current'),
- repo.getFuturesPositions(),
- ]).then<dynamic>((v) => v)
- : Future<dynamic>.value(null),
- _traderId.isNotEmpty // [5] 历史带单
- ? repo
- .getTraderOrders(traderId: _traderId, type: 'history')
- .then<dynamic>((v) => v)
- : Future<dynamic>.value(null),
- ]);
- final info = results[0] as Map<String, dynamic>?;
- final tags = results[1] as List<Map<String, dynamic>>;
- final followerCount = results[2] as int;
- final followersList = results[3] as List<Map<String, dynamic>>;
- // 当前带单:enriching with futures position data
- List<Map<String, dynamic>> enrichedCurrentOrders = [];
- if (_traderId.isNotEmpty && results[4] != null) {
- final pair = results[4] as List;
- final currentRaw = pair[0] as List<Map<String, dynamic>>;
- final futuresPos = pair[1] as List<Map<String, dynamic>>;
- final futuresMap = <String, Map<String, dynamic>>{
- for (final p in futuresPos)
- if (p['id']?.toString().isNotEmpty == true) p['id'].toString(): p,
- };
- enrichedCurrentOrders = currentRaw.map((o) {
- final pid = o['positionId']?.toString() ??
- o['traderPositionId']?.toString() ??
- '';
- final fp = pid.isNotEmpty ? futuresMap[pid] : null;
- if (fp == null) return o;
- final coin = fp['coin'] as Map<String, dynamic>? ?? {};
- final pricePrecision = (coin['coinScale'] as num?)?.toInt() ?? 2;
- return <String, dynamic>{
- ...o,
- 'marginRate': fp['marginRate'],
- if (fp['estimatedBlastPrice'] != null)
- 'estimatedBlastPrice': fp['estimatedBlastPrice'],
- '_pricePrecision': pricePrecision,
- };
- }).toList();
- }
- final historyOrders = _traderId.isNotEmpty && results[5] != null
- ? results[5] as List<Map<String, dynamic>>
- : <Map<String, dynamic>>[];
- // 带单合约列表(与交易员详情页一致,标题走 l10n.tradingContracts)
- List<String> traderSymbols = [];
- if (_traderId.isNotEmpty) {
- try {
- final symRaw = await repo.getTraderSymbols(_traderId);
- traderSymbols = symRaw
- .map((s) =>
- s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '')
- .where((n) => n.isNotEmpty)
- .toList()
- ..sort();
- } catch (_) {}
- }
- if (context.mounted) {
- setState(() {
- _traderInfo = info;
- _tags = tags;
- _followerCount = followerCount;
- // 跟单用户
- _followers = followersList;
- _loadingFollowers = false;
- _followersLoaded = true;
- _followersHasMore = followersList.length >= _followersPageSize;
- // 当前带单
- _currentOrders = enrichedCurrentOrders;
- _loadingCurrentOrders = false;
- _currentOrdersLoaded = true;
- // 历史带单
- _historyOrders = historyOrders;
- _loadingHistoryOrders = false;
- _historyOrdersLoaded = true;
- _traderSymbols = traderSymbols;
- // 最后关掉骨架
- _loadingProfile = false;
- });
- }
- } catch (e) {
- if (context.mounted) setState(() => _loadingProfile = false);
- }
- }
- /// 仅刷新交易员信息卡(昵称/签名等),不重新加载订单列表
- Future<void> _refreshProfile() async {
- try {
- final repo = ref.read(copyTradingRepositoryProvider);
- final followerInfo = await repo.getFollowerInfo();
- final traderId = followerInfo?['id']?.toString() ?? '';
- if (traderId.isNotEmpty) {
- final info = await repo.getTraderInfo(traderId);
- List<String> sym = [];
- try {
- final symRaw = await repo.getTraderSymbols(traderId);
- sym = symRaw
- .map((s) =>
- s['symbolName']?.toString() ?? s['symbol']?.toString() ?? '')
- .where((n) => n.isNotEmpty)
- .toList()
- ..sort();
- } catch (_) {}
- if (context.mounted) {
- setState(() {
- _traderId = traderId;
- _traderInfo = info;
- _traderSymbols = sym;
- });
- }
- }
- } catch (_) {}
- }
- void _onTabChanged(int index) {
- switch (index) {
- case 0:
- if (!_followersLoaded) _loadFollowers();
- break;
- case 1:
- if (!_currentOrdersLoaded) _loadCurrentOrders();
- break;
- case 2:
- if (!_historyOrdersLoaded) _loadHistoryOrders();
- break;
- }
- }
- Future<void> _loadFollowers() async {
- if (!context.mounted) return;
- setState(() {
- _loadingFollowers = true;
- _followersPage = 1;
- _followersHasMore = true;
- });
- try {
- final list = await ref
- .read(copyTradingRepositoryProvider)
- .getMyFollowers(page: 1, pageSize: _followersPageSize);
- if (context.mounted)
- setState(() {
- _followers = list;
- _loadingFollowers = false;
- _followersLoaded = true;
- _followersHasMore = list.length >= _followersPageSize;
- });
- } catch (_) {
- if (context.mounted)
- setState(() {
- _loadingFollowers = false;
- _followersLoaded = true;
- });
- }
- }
- Future<void> _loadMoreFollowers() async {
- if (!_followersHasMore || _followersLoadingMore || _loadingFollowers)
- return;
- final nextPage = _followersPage + 1;
- if (!context.mounted) return;
- setState(() => _followersLoadingMore = true);
- try {
- final list = await ref
- .read(copyTradingRepositoryProvider)
- .getMyFollowers(page: nextPage, pageSize: _followersPageSize);
- if (context.mounted)
- setState(() {
- _followers = [..._followers, ...list];
- _followersPage = nextPage;
- _followersHasMore = list.length >= _followersPageSize;
- _followersLoadingMore = false;
- });
- } catch (_) {
- if (context.mounted) setState(() => _followersLoadingMore = false);
- }
- }
- Future<void> _loadCurrentOrders() async {
- if (_traderId.isEmpty) {
- if (context.mounted)
- setState(() {
- _loadingCurrentOrders = false;
- _currentOrdersLoaded = true;
- });
- return;
- }
- if (!context.mounted) return;
- setState(() => _loadingCurrentOrders = true);
- try {
- final repo = ref.read(copyTradingRepositoryProvider);
- final results = await Future.wait([
- repo.getTraderOrders(traderId: _traderId, type: 'current'),
- repo.getFuturesPositions(),
- ]);
- final list = results[0];
- final futuresPositions = results[1];
- // Build positionId → futures position map for cross-referencing
- final futuresMap = <String, Map<String, dynamic>>{
- for (final p in futuresPositions)
- if (p['id']?.toString().isNotEmpty == true) p['id'].toString(): p,
- };
- // Enrich each copy order with marginRate from the matching futures position
- final enriched = list.map((o) {
- final pid = o['positionId']?.toString() ??
- o['traderPositionId']?.toString() ??
- '';
- final fp = pid.isNotEmpty ? futuresMap[pid] : null;
- if (fp == null) return o;
- final coin = fp['coin'] as Map<String, dynamic>? ?? {};
- final pricePrecision = (coin['coinScale'] as num?)?.toInt() ?? 2;
- return <String, dynamic>{
- ...o,
- 'marginRate': fp['marginRate'],
- if (fp['estimatedBlastPrice'] != null)
- 'estimatedBlastPrice': fp['estimatedBlastPrice'],
- '_pricePrecision': pricePrecision,
- };
- }).toList();
- if (context.mounted) {
- setState(() {
- _currentOrders = enriched;
- _loadingCurrentOrders = false;
- _currentOrdersLoaded = true;
- });
- }
- } catch (_) {
- if (context.mounted)
- setState(() {
- _loadingCurrentOrders = false;
- _currentOrdersLoaded = true;
- });
- }
- }
- Future<void> _loadHistoryOrders() async {
- if (_traderId.isEmpty) {
- if (context.mounted)
- setState(() {
- _loadingHistoryOrders = false;
- _historyOrdersLoaded = true;
- });
- return;
- }
- if (!context.mounted) return;
- setState(() => _loadingHistoryOrders = true);
- try {
- final list = await ref
- .read(copyTradingRepositoryProvider)
- .getTraderOrders(traderId: _traderId, type: 'history');
- if (context.mounted)
- setState(() {
- _historyOrders = list;
- _loadingHistoryOrders = false;
- _historyOrdersLoaded = true;
- });
- } catch (_) {
- if (context.mounted)
- setState(() {
- _loadingHistoryOrders = false;
- _historyOrdersLoaded = true;
- });
- }
- }
- Future<void> _removeFollower(String followId) async {
- final confirmed = await _showRemoveConfirmDialog();
- if (!confirmed || !context.mounted) return;
- try {
- await ref.read(copyTradingRepositoryProvider).removeFollower(followId);
- if (context.mounted) {
- setState(() {
- _followers.removeWhere((f) => f['id']?.toString() == followId);
- if (_followerCount != null && _followerCount! > 0) {
- _followerCount = _followerCount! - 1;
- }
- });
- showTopToast(context,
- message: AppLocalizations.of(context)!.removedSuccess,
- backgroundColor: AppColors.rise);
- }
- } catch (e) {
- if (context.mounted) {
- showTopToast(context,
- message: extractErrorMessage(e), backgroundColor: AppColors.fall);
- }
- }
- }
- Future<bool> _showRemoveConfirmDialog() async {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- return await showDialog<bool>(
- context: context,
- builder: (ctx) => Dialog(
- backgroundColor: cs.surface,
- shape:
- RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const SizedBox(height: 24),
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 20),
- child: Text(
- l10n.confirmRemoveFollower,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 17,
- fontWeight: FontWeight.w600),
- ),
- ),
- const SizedBox(height: 12),
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 20),
- child: Text(
- l10n.removeFollowerMsg,
- style: TextStyle(
- color: cs.onSurface.withAlpha(180), fontSize: 14),
- textAlign: TextAlign.center,
- ),
- ),
- const SizedBox(height: 24),
- Divider(
- height: 1,
- thickness: 1,
- color: cs.outlineVariant.withAlpha(60)),
- // 两个按钮各占一半
- IntrinsicHeight(
- child: Row(
- children: [
- Expanded(
- child: GestureDetector(
- onTap: () => Navigator.of(ctx).pop(false),
- child: Container(
- height: 52,
- decoration: BoxDecoration(
- borderRadius: const BorderRadius.only(
- bottomLeft: Radius.circular(16)),
- ),
- alignment: Alignment.center,
- child: Text(l10n.cancelLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 16)),
- ),
- ),
- ),
- VerticalDivider(
- width: 1,
- thickness: 1,
- color: cs.outlineVariant.withAlpha(60)),
- Expanded(
- child: GestureDetector(
- onTap: () => Navigator.of(ctx).pop(true),
- child: Container(
- height: 52,
- decoration: const BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.only(
- bottomRight: Radius.circular(16)),
- ),
- alignment: Alignment.center,
- child: Text(l10n.confirm,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 16,
- fontWeight: FontWeight.w600)),
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ) ??
- false;
- }
- /// 格式化数字,向下截断(不四舍五入,对应 Android RoundingMode.DOWN)
- String _fmt(dynamic v, {int decimals = 2}) {
- if (v == null) return '--';
- final str = v.toString().trim();
- if (str.isEmpty) return '--';
- final d = double.tryParse(str);
- if (d == null) return str;
- final isNeg = str.startsWith('-');
- final absStr = isNeg ? str.substring(1) : str;
- final dotIdx = absStr.indexOf('.');
- String truncated;
- if (decimals == 0 || dotIdx < 0) {
- truncated = dotIdx < 0 ? absStr : absStr.substring(0, dotIdx);
- } else {
- final frac = absStr.substring(dotIdx + 1);
- truncated =
- '${absStr.substring(0, dotIdx)}.${frac.length >= decimals ? frac.substring(0, decimals) : frac.padRight(decimals, '0')}';
- }
- final parts = truncated.split('.');
- final intFmt = parts[0].replaceAllMapped(
- RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]},');
- final result = decimals > 0
- ? '$intFmt.${parts.length > 1 ? parts[1] : '0' * decimals}'
- : intFmt;
- return isNeg ? '-$result' : result;
- }
- Widget _buildTradingContractsStrip(ColorScheme cs) {
- if (_traderSymbols.isEmpty) {
- return const SizedBox.shrink();
- }
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- GestureDetector(
- onTap: () => setState(() => _symbolExpanded = !_symbolExpanded),
- behavior: HitTestBehavior.opaque,
- child: Row(
- children: [
- Text(
- l10n.tradingContracts,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w700,
- ),
- ),
- const Spacer(),
- Icon(
- _symbolExpanded
- ? Icons.keyboard_arrow_up
- : Icons.keyboard_arrow_down,
- color: cs.onSurface.withAlpha(153),
- size: 20,
- ),
- ],
- ),
- ),
- if (_symbolExpanded) ...[
- const SizedBox(height: 10),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: _traderSymbols.map((sym) {
- return Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
- decoration: BoxDecoration(
- color: AppColors.brand.withValues(alpha: 0.12),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: AppColors.brand.withValues(alpha: 0.6),
- width: 1.5,
- ),
- ),
- child: Text(
- sym,
- style: const TextStyle(
- color: AppColors.brand,
- fontSize: 13,
- fontWeight: FontWeight.w600,
- ),
- ),
- );
- }).toList(),
- ),
- ],
- ],
- ),
- );
- }
- @override
- Widget build(BuildContext context) {
- ref.watch(localeProvider);
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- return Scaffold(
- appBar: AppBar(
- leading: IconButton(
- icon: const Icon(Icons.arrow_back_ios, size: 18),
- onPressed: () => context.pop(),
- ),
- title: Text(l10n.myTrades,
- style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
- actions: [
- IconButton(
- icon: Icon(Icons.settings_outlined, color: cs.onSurface),
- onPressed: () => context.push('/trader-settings').then((_) {
- if (context.mounted) _refreshProfile();
- }),
- ),
- ],
- ),
- body: _loadingProfile
- ? const _MyTradesFullSkeleton()
- : Column(
- children: [
- // ── 交易员信息卡 ──────────────────────────
- _ProfileCard(traderInfo: _traderInfo, fmt: _fmt, tags: _tags),
- _buildTradingContractsStrip(cs),
- // ── Tab 栏 ────────────────────────────────
- Container(
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: cs.outlineVariant.withAlpha(60), width: 1)),
- ),
- child: TabBar(
- controller: _tabController,
- indicator: StretchTabIndicator(
- controller: _tabController,
- color: AppColors.brand,
- ),
- indicatorSize: TabBarIndicatorSize.tab,
- dividerColor: Colors.transparent,
- tabs: [
- Tab(
- text: (_followerCount != null && _followerCount! > 0)
- ? '${l10n.followersTab}($_followerCount)'
- : l10n.followersTab),
- Tab(text: l10n.currentCopyOrders),
- Tab(text: l10n.historyCopyOrders),
- ],
- ),
- ),
- // ── Tab 内容(PageView 支持左右滑动)─────────────
- Expanded(
- child: PageView(
- controller: _pageController,
- physics: const BouncingScrollPhysics(
- parent: AlwaysScrollableScrollPhysics(),
- ),
- onPageChanged: (index) {
- if (_tabController.indexIsChanging) return;
- _tabController.index = index;
- },
- children: [
- _FollowersTab(
- loading: _loadingFollowers,
- loaded: _followersLoaded,
- followers: _followers,
- hasMore: _followersHasMore,
- loadingMore: _followersLoadingMore,
- onRemove: _removeFollower,
- fmt: _fmt,
- onRefresh: _loadFollowers,
- onLoadMore: _loadMoreFollowers,
- ),
- _OrdersTab(
- loading: _loadingCurrentOrders,
- loaded: _currentOrdersLoaded,
- orders: _currentOrders,
- onLoad: _loadCurrentOrders,
- fmt: _fmt,
- onRefresh: _loadCurrentOrders,
- ),
- _OrdersTab(
- loading: _loadingHistoryOrders,
- loaded: _historyOrdersLoaded,
- orders: _historyOrders,
- onLoad: _loadHistoryOrders,
- fmt: _fmt,
- isHistory: true,
- onRefresh: _loadHistoryOrders,
- ),
- ],
- ),
- ),
- ],
- ),
- );
- }
- }
- // ── 交易员信息卡 ──────────────────────────────────────────
- class _ProfileCard extends StatelessWidget {
- const _ProfileCard(
- {required this.traderInfo, required this.fmt, this.tags = const []});
- final Map<String, dynamic>? traderInfo;
- final String Function(dynamic, {int decimals}) fmt;
- final List<Map<String, dynamic>> tags;
- String get _nickname => traderInfo?['nickname']?.toString() ?? '--';
- String get _description => traderInfo?['description']?.toString() ?? '';
- String get _levelName => traderInfo?['levelName']?.toString() ?? '';
- String? get _avatarUrl => traderInfo == null
- ? null
- : resolvedAvatarUrlFromRecord(Map<String, dynamic>.from(traderInfo!));
- String get _followingCurrent => traderInfo?['following']?.toString() ?? '--';
- String get _followingMax => traderInfo?['maxFollow']?.toString() ?? '--';
- String get _joinDays =>
- traderInfo?['registerDays']?.toString() ??
- traderInfo?['settledDays']?.toString() ??
- '--';
- String get _moneyStrength => traderInfo?['moneyStrength']?.toString() ?? '--';
- String get _cumulativeProfit =>
- fmt(traderInfo?['profitAmount'] ?? traderInfo?['totalFollowProfit']);
- String get _cumulativeFollowers =>
- traderInfo?['followCustomer']?.toString() ?? '--';
- String get _totalTradeDays => traderInfo?['tradingDays']?.toString() ?? '--';
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final letter = _nickname.isNotEmpty ? _nickname[0].toUpperCase() : 'T';
- return Container(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 头像 + 昵称 + 描述
- Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _Avatar(
- letter: letter, levelName: _levelName, avatarUrl: _avatarUrl),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(_nickname,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w700)),
- if (_description.isNotEmpty)
- Padding(
- padding: const EdgeInsets.only(top: 4),
- child: Text(
- _description,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 13),
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- ),
- if (tags.isNotEmpty)
- Padding(
- padding: const EdgeInsets.only(top: 8),
- child: SingleChildScrollView(
- scrollDirection: Axis.horizontal,
- child: Row(
- children: tags.map((tag) {
- final name = tag['name']?.toString() ?? '';
- if (name.isEmpty) return const SizedBox.shrink();
- return Padding(
- padding: const EdgeInsets.only(right: 6),
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 10, vertical: 3),
- decoration: BoxDecoration(
- color: AppColors.tagBlueBg,
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- name,
- style: const TextStyle(
- color: AppColors.tagBlue, fontSize: 12),
- ),
- ),
- );
- }).toList(),
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
- // 统计网格:浅灰色背景圆角卡片(无边框)
- Builder(builder: (context) {
- final l10n = AppLocalizations.of(context)!;
- return Container(
- padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(10),
- borderRadius: BorderRadius.circular(10),
- ),
- child: Column(
- children: [
- // 第1行
- Row(
- children: [
- _StatCell(
- label: l10n.currentFollowersLabel,
- value: _followingCurrent,
- valueSuffix: ' / $_followingMax',
- ),
- _StatCell(
- label: l10n.settledDaysTitle,
- value: _joinDays,
- alignCenter: true),
- _StatCell(
- label: l10n.fundStrength,
- value: '$_moneyStrength USDT',
- alignEnd: true),
- ],
- ),
- const SizedBox(height: 12),
- // 第2行
- Row(
- children: [
- _StatCell(
- label: l10n.cumCopyProfitUsdt,
- value: _cumulativeProfit),
- _StatCell(
- label: l10n.cumFollowerCount,
- value: _cumulativeFollowers,
- alignCenter: true),
- _StatCell(
- label: l10n.cumTradingDays,
- value: _totalTradeDays,
- alignEnd: true),
- ],
- ),
- ],
- ),
- );
- }),
- ],
- ),
- );
- }
- }
- class _Avatar extends StatelessWidget {
- const _Avatar(
- {required this.letter, required this.levelName, this.avatarUrl});
- final String letter;
- final String levelName;
- final String? avatarUrl;
- @override
- Widget build(BuildContext context) {
- final hasAvatar = avatarUrl != null && avatarUrl!.isNotEmpty;
- return SizedBox(
- width: 54,
- height: 64,
- child: Stack(
- alignment: Alignment.topCenter,
- children: [
- if (hasAvatar)
- ClipOval(
- child: Image.network(
- avatarUrl!,
- width: 54,
- height: 54,
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => _LetterAvatar(letter: letter),
- ),
- )
- else
- _LetterAvatar(letter: letter),
- if (levelName.isNotEmpty)
- Positioned(
- bottom: 0,
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(10),
- ),
- child: Text(levelName,
- style: const TextStyle(
- color: Colors.black,
- fontSize: 10,
- fontWeight: FontWeight.w700)),
- ),
- ),
- ],
- ),
- );
- }
- }
- class _LetterAvatar extends StatelessWidget {
- const _LetterAvatar({required this.letter});
- final String letter;
- @override
- Widget build(BuildContext context) {
- return Container(
- width: 54,
- height: 54,
- decoration:
- const BoxDecoration(color: Color(0xFF5B7BE8), shape: BoxShape.circle),
- child: Center(
- child: Text(letter,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 22,
- fontWeight: FontWeight.w700))),
- );
- }
- }
- class _StatCell extends StatelessWidget {
- const _StatCell(
- {required this.label,
- required this.value,
- this.valueSuffix,
- this.alignEnd = false,
- this.alignCenter = false});
- final String label;
- final String value;
- final String? valueSuffix; // 灰色后缀,如 " / 300"
- final bool alignEnd;
- final bool alignCenter;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final align = alignEnd
- ? CrossAxisAlignment.end
- : alignCenter
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.start;
- final greyColor = cs.onSurface.withAlpha(120);
- return Expanded(
- child: Column(
- crossAxisAlignment: align,
- children: [
- Text(label, style: TextStyle(color: greyColor, fontSize: 11)),
- const SizedBox(height: 3),
- valueSuffix != null
- ? RichText(
- text: TextSpan(
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w600,
- color: cs.onSurface),
- children: [
- TextSpan(text: value),
- TextSpan(
- text: valueSuffix,
- style: TextStyle(
- color: greyColor, fontWeight: FontWeight.w400)),
- ],
- ),
- )
- : Text(value,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ],
- ),
- );
- }
- }
- // ── 跟单用户 Tab ──────────────────────────────────────────
- class _FollowersTab extends StatelessWidget {
- const _FollowersTab({
- required this.loading,
- required this.loaded,
- required this.followers,
- required this.hasMore,
- required this.loadingMore,
- required this.onRemove,
- required this.fmt,
- required this.onRefresh,
- required this.onLoadMore,
- });
- final bool loading;
- final bool loaded;
- final List<Map<String, dynamic>> followers;
- final bool hasMore;
- final bool loadingMore;
- final void Function(String id) onRemove;
- final String Function(dynamic, {int decimals}) fmt;
- final Future<void> Function() onRefresh;
- final VoidCallback onLoadMore;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isLoading = !loaded || (loading && followers.isEmpty);
- return NotificationListener<ScrollNotification>(
- onNotification: (n) {
- if (!isLoading &&
- n is ScrollEndNotification &&
- n.metrics.pixels >= n.metrics.maxScrollExtent - 200) {
- onLoadMore();
- }
- return false;
- },
- child: AppRefreshIndicator(
- onRefresh: onRefresh,
- child: ListView.builder(
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.only(bottom: 16),
- itemCount:
- isLoading ? 4 : (followers.isEmpty ? 1 : followers.length + 1),
- itemBuilder: (_, i) {
- if (isLoading) return const _FollowerCardSkeleton();
- if (followers.isEmpty) {
- return SizedBox(
- height: 200,
- child: Center(
- child: Text(AppLocalizations.of(context)!.noFollowers,
- style: TextStyle(color: cs.onSurface.withAlpha(100))),
- ),
- );
- }
- if (i >= followers.length) {
- if (loadingMore) {
- return const Padding(
- padding: EdgeInsets.symmetric(vertical: 16),
- child: Center(
- child: CircularProgressIndicator(
- color: AppColors.brand, strokeWidth: 2)),
- );
- }
- if (!hasMore) {
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 16),
- child: Center(
- child: Text(AppLocalizations.of(context)!.noMore,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 12))),
- );
- }
- return const SizedBox(height: 16);
- }
- return _FollowerCard(
- follower: followers[i], onRemove: onRemove, fmt: fmt);
- },
- ),
- ),
- );
- }
- }
- class _FollowerCard extends StatelessWidget {
- const _FollowerCard(
- {required this.follower, required this.onRemove, required this.fmt});
- final Map<String, dynamic> follower;
- final void Function(String) onRemove;
- final String Function(dynamic, {int decimals}) fmt;
- static const _avatarColors = [
- Color(0xFF5B7BE8),
- Color(0xFFf7931a),
- Color(0xFF9945ff),
- Color(0xFFf3ba2f),
- Color(0xFF2775ca),
- Color(0xFF00aae4),
- ];
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final uid = follower['id']?.toString() ?? '';
- final nickname = follower['nickname']?.toString() ?? uid;
- final display = nickname.isNotEmpty ? nickname : uid;
- final colorIdx =
- uid.isNotEmpty ? uid.codeUnitAt(0) % _avatarColors.length : 0;
- final letter = display.isNotEmpty ? display[0].toUpperCase() : '?';
- // 跟随人数
- final following = follower['following']?.toString();
- final maxFollow = follower['maxFollow']?.toString();
- final hasFollowInfo = following != null || maxFollow != null;
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withAlpha(15),
- blurRadius: 8,
- offset: const Offset(0, 2)),
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 头部:头像 + 昵称+跟随人数 + 移除按钮
- Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Container(
- width: 44,
- height: 44,
- decoration: BoxDecoration(
- color: _avatarColors[colorIdx], shape: BoxShape.circle),
- child: Center(
- child: Text(letter,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 18,
- fontWeight: FontWeight.w700)),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- display.isNotEmpty
- ? display
- : AppLocalizations.of(context)!.copyUser,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w600),
- ),
- if (hasFollowInfo) ...[
- const SizedBox(height: 4),
- Row(
- children: [
- Icon(Icons.people_outline,
- size: 13, color: cs.onSurface.withAlpha(120)),
- const SizedBox(width: 4),
- Text(
- maxFollow != null
- ? AppLocalizations.of(context)!
- .followersMaxLabel(
- following ?? '--', maxFollow)
- : AppLocalizations.of(context)!
- .followersCountLabel(following ?? '--'),
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 12),
- ),
- ],
- ),
- ],
- ],
- ),
- ),
- const SizedBox(width: 8),
- OutlinedButton(
- onPressed: () => onRemove(uid),
- style: OutlinedButton.styleFrom(
- side: BorderSide(color: cs.onSurface, width: 1.5),
- foregroundColor: cs.onSurface,
- padding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(20)),
- minimumSize: Size.zero,
- tapTargetSize: MaterialTapTargetSize.shrinkWrap,
- ),
- child: Text(AppLocalizations.of(context)!.remove,
- style: const TextStyle(
- fontSize: 12, fontWeight: FontWeight.w500)),
- ),
- ],
- ),
- const SizedBox(height: 12),
- Divider(
- height: 1,
- thickness: 0.5,
- color: cs.outlineVariant.withAlpha(80)),
- const SizedBox(height: 12),
- // 底部统计行(含竖向分隔线)
- Builder(builder: (context) {
- final l10n = AppLocalizations.of(context)!;
- return IntrinsicHeight(
- child: Row(
- children: [
- _FollowerStat(
- label: l10n.accountEquityUsdt,
- value:
- fmt(follower['balance'] ?? follower['totalBalance'])),
- VerticalDivider(
- width: 1,
- thickness: 0.8,
- color: Colors.grey.withAlpha(100)),
- _FollowerStat(
- label: l10n.cumProfitShareUsdt,
- value: fmt(follower['totalProfitSharing']),
- alignCenter: true),
- VerticalDivider(
- width: 1,
- thickness: 0.8,
- color: Colors.grey.withAlpha(100)),
- _FollowerStat(
- label: l10n.lastProfitShare,
- value: fmt(follower['lastProfitSharing']),
- alignEnd: true),
- ],
- ),
- );
- }),
- // 跟随时间
- Builder(builder: (context) {
- final followTime = follower['followTime']?.toString() ?? '';
- if (followTime.isEmpty) return const SizedBox.shrink();
- return Padding(
- padding: const EdgeInsets.only(top: 8),
- child: Row(
- children: [
- Icon(Icons.access_time,
- size: 12, color: cs.onSurface.withAlpha(100)),
- const SizedBox(width: 4),
- Text(
- '${AppLocalizations.of(context)!.followerFollowTime}:$followTime',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12),
- ),
- ],
- ),
- );
- }),
- ],
- ),
- );
- }
- }
- class _FollowerStat extends StatelessWidget {
- const _FollowerStat(
- {required this.label,
- required this.value,
- this.alignEnd = false,
- this.alignCenter = false});
- final String label;
- final String value;
- final bool alignEnd;
- final bool alignCenter;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final align = alignEnd
- ? CrossAxisAlignment.end
- : alignCenter
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.start;
- return Expanded(
- child: Column(
- crossAxisAlignment: align,
- children: [
- Text(label,
- style:
- TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
- const SizedBox(height: 2),
- Text(value,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w600)),
- ],
- ),
- );
- }
- }
- // ── 带单仓位 Tab ──────────────────────────────────────────
- class _OrdersTab extends StatelessWidget {
- const _OrdersTab({
- required this.loading,
- required this.loaded,
- required this.orders,
- required this.onLoad,
- required this.fmt,
- required this.onRefresh,
- this.isHistory = false,
- });
- final bool loading;
- final bool loaded;
- final List<Map<String, dynamic>> orders;
- final VoidCallback onLoad;
- final String Function(dynamic, {int decimals}) fmt;
- final Future<void> Function() onRefresh;
- final bool isHistory;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isLoading = !loaded || (loading && orders.isEmpty);
- return AppRefreshIndicator(
- onRefresh: onRefresh,
- child: ListView.builder(
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.only(bottom: 16),
- itemCount: isLoading ? 4 : (orders.isEmpty ? 1 : orders.length),
- itemBuilder: (_, i) {
- if (isLoading) return const _OrderCardSkeleton();
- if (orders.isEmpty) {
- return SizedBox(
- height: 200,
- child: Center(
- child: Text(
- isHistory
- ? AppLocalizations.of(context)!.noHistoryTrades
- : AppLocalizations.of(context)!.noCurrentTrades,
- style: TextStyle(color: cs.onSurface.withAlpha(100)),
- ),
- ),
- );
- }
- if (i < 0 || i >= orders.length) return const SizedBox.shrink();
- return _OrderCard(order: orders[i], fmt: fmt, isHistory: isHistory);
- },
- ),
- );
- }
- }
- class _OrderCard extends StatelessWidget {
- const _OrderCard(
- {required this.order, required this.fmt, this.isHistory = false});
- final Map<String, dynamic> order;
- final String Function(dynamic, {int decimals}) fmt;
- final bool isHistory;
- /// 按交易对价格精度截断(RoundingMode.DOWN),去除尾部零,与安卓 coinScale 逻辑一致
- String _fmtWithScale(double v, int scale) {
- if (scale < 0) scale = 0;
- final factor = scale == 0
- ? 1.0
- : List.generate(scale, (_) => 10).fold(1.0, (a, b) => a * b);
- final truncated = v >= 0
- ? (v * factor).floorToDouble() / factor
- : (v * factor).ceilToDouble() / factor;
- String s = truncated.toStringAsFixed(scale);
- if (s.contains('.')) {
- s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
- }
- return s.isEmpty ? '0' : s;
- }
- String _fmtTimestamp(dynamic ts) {
- if (ts == null) return '--';
- final ms = int.tryParse(ts.toString());
- if (ms == null) return ts.toString();
- // 后台时间戳为 UTC 毫秒,统一转为 UTC+8(北京时间)展示
- final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
- .add(const Duration(hours: 8));
- final y = dt.year;
- final mo = dt.month.toString().padLeft(2, '0');
- final d = dt.day.toString().padLeft(2, '0');
- final h = dt.hour.toString().padLeft(2, '0');
- final mi = dt.minute.toString().padLeft(2, '0');
- final s = dt.second.toString().padLeft(2, '0');
- return '$y-$mo-$d $h:$mi:$s';
- }
- /// 数量字段专用:4位小数、向下截断、去尾零(对应 Android textDigital=4 + stripTrailingZeros)
- String _fmtQty(dynamic v) {
- final s = fmt(v, decimals: 4);
- if (s == '--' || !s.contains('.')) return s;
- return s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
- }
- @override
- Widget build(BuildContext context) {
- return isHistory ? _buildHistory(context) : _buildCurrent(context);
- }
- // ── 当前带单卡片 ────────────────────────────────────────
- Widget _buildCurrent(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- final symbol = order['symbol']?.toString() ?? '--';
- // 提取基础币种:BTC/USDT → BTC
- final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol;
- final isLong = (order['direction']?.toString() ?? '0') == '0';
- final leverage = order['leverage']?.toString() ?? '--';
- final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
- final profitColor = profit >= 0 ? AppColors.rise : AppColors.fall;
- final profitRateRaw =
- double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
- final profitRateStr =
- '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
- final profitRateColor =
- profitRateRaw >= 0 ? AppColors.rise : AppColors.fall;
- final openTime = _fmtTimestamp(order['openTime']);
- final positionId = order['positionId']?.toString() ??
- order['traderPositionId']?.toString() ??
- '--';
- // ── 保证金比率:参照合约持仓页公式 principalAmount / (totalPosition * currentPrice) * 100
- // 若 API 直接返回 marginRate 则优先使用
- final principalAmount =
- double.tryParse(order['principalAmount']?.toString() ?? '0') ?? 0.0;
- final apiMarginRate =
- double.tryParse(order['marginRate']?.toString() ?? '');
- final currentPrice =
- double.tryParse(order['currentPrice']?.toString() ?? '0') ?? 0.0;
- final qty =
- double.tryParse(order['totalPosition']?.toString() ?? '0') ?? 0.0;
- String marginRatioStr;
- if (apiMarginRate != null && apiMarginRate > 0) {
- marginRatioStr = '${apiMarginRate.toStringAsFixed(2)}%';
- } else if (qty > 0 && currentPrice > 0) {
- marginRatioStr =
- '${(principalAmount / (qty * currentPrice) * 100).toStringAsFixed(2)}%';
- } else {
- marginRatioStr = '--';
- }
- // ── 强平价格:按交易对价格精度(coinScale)截断显示,与安卓合约持仓页保持一致
- final pricePrecision = (order['_pricePrecision'] as int?) ?? 2;
- String liquidationPriceStr = '--';
- final blastVal =
- double.tryParse(order['estimatedBlastPrice']?.toString() ?? '');
- if (blastVal != null && blastVal > 0) {
- liquidationPriceStr = _fmtWithScale(blastVal, pricePrecision);
- }
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- border: isDark
- ? null
- : Border.all(color: AppColors.lightBorder, width: 0.5),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 标题行
- Row(
- children: [
- Text(symbol,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w700)),
- const SizedBox(width: 4),
- Text(l10n.perpetual,
- style: TextStyle(color: cs.onSurface, fontSize: 13)),
- const SizedBox(width: 8),
- _Badge(
- text: isLong ? l10n.openLong : l10n.openShort,
- color: isLong ? AppColors.rise : AppColors.fall),
- const SizedBox(width: 6),
- _Badge(
- text: l10n.crossMargin, color: cs.onSurface.withAlpha(120)),
- const SizedBox(width: 6),
- _Badge(text: '${leverage}X', color: cs.onSurface.withAlpha(120)),
- ],
- ),
- const SizedBox(height: 12),
- // 未实现盈亏(大字)+ 收益率
- Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.unrealizedPnlUsdt,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 11)),
- const SizedBox(height: 3),
- Text('${profit >= 0 ? '+' : ''}${fmt(profit)}',
- style: TextStyle(
- color: profitColor,
- fontSize: 22,
- fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.returnRate,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 11)),
- const SizedBox(height: 3),
- Text(profitRateStr,
- style: TextStyle(
- color: profitRateColor,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ],
- ),
- ],
- ),
- const SizedBox(height: 12),
- const Divider(height: 1),
- const SizedBox(height: 10),
- Row(
- children: [
- _OrderStat(
- label: l10n.positionSizeWithCoin(baseCoin),
- value: _fmtQty(order['totalPosition'])),
- _OrderStat(
- label: l10n.marginUsdt,
- value: fmt(principalAmount),
- alignCenter: true),
- _OrderStat(
- label: l10n.marginRatio,
- value: marginRatioStr,
- alignEnd: true),
- ],
- ),
- const SizedBox(height: 10),
- Row(
- children: [
- _OrderStat(
- label: l10n.openAvgPriceUsdt, value: fmt(order['openPrice'])),
- _OrderStat(
- label: l10n.currentPriceUsdt,
- value: fmt(order['currentPrice']),
- alignCenter: true),
- _OrderStat(
- label: l10n.liqPriceUsdt,
- value: liquidationPriceStr,
- alignEnd: true),
- ],
- ),
- const SizedBox(height: 10),
- const Divider(height: 1),
- const SizedBox(height: 8),
- Row(
- children: [
- Expanded(
- child: Text(l10n.openTimeWithValue(openTime),
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 11)),
- ),
- GestureDetector(
- onTap: () {
- Clipboard.setData(ClipboardData(text: positionId));
- showTopToast(context,
- message: l10n.positionIdCopied,
- backgroundColor: AppColors.rise);
- },
- child: Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text('${l10n.positionIdPrefix}$positionId',
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(width: 4),
- Icon(Icons.content_copy,
- size: 14, color: cs.onSurface.withAlpha(120)),
- ],
- ),
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- // ── 历史带单卡片 ────────────────────────────────────────
- Widget _buildHistory(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- final symbol = order['symbol']?.toString() ?? '--';
- final baseCoin = symbol.contains('/') ? symbol.split('/')[0] : symbol;
- final isLong = (order['direction']?.toString() ?? '0') == '0';
- final leverage = order['leverage']?.toString() ?? '--';
- final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
- final profitColor = profit >= 0 ? AppColors.rise : AppColors.fall;
- final profitRateRaw =
- double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
- final profitRateStr =
- '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
- final profitRateColor =
- profitRateRaw >= 0 ? AppColors.rise : AppColors.fall;
- final openTime = _fmtTimestamp(order['openTime']);
- final closeTime = _fmtTimestamp(order['closeTime']);
- final headerBg = cs.onSurface.withAlpha(22);
- final bodyBg = cs.onSurface.withAlpha(8);
- final dividerColor = cs.outlineVariant.withAlpha(80);
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- ),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // ── 第一部分:深灰色标题行 ─────────────────────────
- Container(
- color: headerBg,
- padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
- child: Row(
- children: [
- Text(symbol,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w700)),
- const SizedBox(width: 4),
- Text(l10n.perpetual,
- style: TextStyle(color: cs.onSurface, fontSize: 13)),
- const SizedBox(width: 8),
- _Badge(
- text: isLong ? l10n.openLong : l10n.openShort,
- color: isLong ? AppColors.rise : AppColors.fall),
- const SizedBox(width: 6),
- _Badge(
- text: l10n.crossMargin,
- color: cs.onSurface.withAlpha(120)),
- const SizedBox(width: 6),
- _Badge(
- text: '${leverage}X', color: cs.onSurface.withAlpha(120)),
- const Spacer(),
- GestureDetector(
- onTap: () => _showShareSheet(context),
- child: Icon(Icons.share_outlined,
- size: 18, color: cs.onSurface.withAlpha(120)),
- ),
- ],
- ),
- ),
- // ── 第二部分:灰色 — 平仓数量 / 已实现盈亏 / 收益率 ──
- Container(
- color: bodyBg,
- padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
- child: Row(
- children: [
- _OrderStat(
- label: l10n.closeSizeWithCoin(baseCoin),
- value: _fmtQty(order['totalPosition'])),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.realizedPnlUsdt,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(height: 2),
- Text(
- '${profit >= 0 ? '+' : ''}${_fmtQty(order['profit'])}',
- style: TextStyle(
- color: profitColor,
- fontSize: 15,
- fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.returnRate,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(height: 2),
- Text(profitRateStr,
- style: TextStyle(
- color: profitRateColor,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ],
- ),
- ],
- ),
- ),
- // ── 分割线 ─────────────────────────────────────────
- Divider(height: 0.5, thickness: 0.5, color: dividerColor),
- // ── 第三部分:灰色 — 开仓均价 / 平仓均价 ─────────────
- Container(
- color: bodyBg,
- padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.openAvgPriceUsdt,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(height: 2),
- Text(fmt(order['openPrice']),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- const SizedBox(height: 4),
- Text(openTime,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.closeAvgPriceUsdt,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(height: 2),
- Text(fmt(order['closePrice']),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- const SizedBox(height: 4),
- Text(closeTime,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- ],
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- void _showShareSheet(BuildContext context) {
- showModalBottomSheet(
- context: context,
- useRootNavigator: true,
- backgroundColor: Colors.transparent,
- isScrollControlled: true,
- builder: (_) => _ShareOrderSheet(order: order, fmt: fmt),
- );
- }
- }
- // ── 骨架屏 ────────────────────────────────────────────────
- /// 「我的带单」首次加载时的全页骨架(交易员信息卡 + Tab 栏)
- class _MyTradesFullSkeleton extends StatelessWidget {
- const _MyTradesFullSkeleton();
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return Column(
- children: [
- // 交易员信息卡骨架
- AppShimmer(
- child: Container(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerCircle(54),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(120, 16),
- const SizedBox(height: 8),
- shimmerBox(200, 13),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
- Container(
- padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(10),
- borderRadius: BorderRadius.circular(10),
- ),
- child: Column(
- children: [
- Row(
- children: List.generate(
- 3,
- (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: i == 1 ? 8.0 : 0),
- child: Column(
- crossAxisAlignment: i == 0
- ? CrossAxisAlignment.start
- : i == 1
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.end,
- children: [
- shimmerBox(55, 11),
- const SizedBox(height: 5),
- shimmerBox(40, 14),
- ],
- ),
- ),
- ))),
- const SizedBox(height: 12),
- Row(
- children: List.generate(
- 3,
- (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: i == 1 ? 8.0 : 0),
- child: Column(
- crossAxisAlignment: i == 0
- ? CrossAxisAlignment.start
- : i == 1
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.end,
- children: [
- shimmerBox(55, 11),
- const SizedBox(height: 5),
- shimmerBox(40, 14),
- ],
- ),
- ),
- ))),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- // Tab 栏骨架
- AppShimmer(
- child: Container(
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: cs.outlineVariant.withAlpha(60), width: 1)),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(
- children: List.generate(
- 3,
- (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: i == 1 ? 8.0 : 0),
- child: shimmerFill(16, radius: 4),
- ),
- )),
- ),
- ),
- ),
- ),
- // 列表骨架
- Expanded(
- child: ListView.builder(
- padding: const EdgeInsets.only(bottom: 16),
- itemCount: 4,
- itemBuilder: (_, __) => const _FollowerCardSkeleton(),
- ),
- ),
- ],
- );
- }
- }
- /// 跟单用户卡片骨架(对应 _FollowerCard 样式)
- class _FollowerCardSkeleton extends StatelessWidget {
- const _FollowerCardSkeleton();
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return AppShimmer(
- child: Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- children: [
- Row(
- children: [
- shimmerCircle(40),
- const SizedBox(width: 10),
- Expanded(child: shimmerBox(100, 14)),
- shimmerBox(55, 32, radius: 20),
- ],
- ),
- const SizedBox(height: 10),
- Row(
- children: List.generate(
- 3,
- (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: i == 1 ? 8.0 : 0),
- child: Column(
- crossAxisAlignment: i == 0
- ? CrossAxisAlignment.start
- : i == 1
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.end,
- children: [
- shimmerBox(50, 11),
- const SizedBox(height: 4),
- shimmerBox(40, 13),
- ],
- ),
- ),
- ))),
- const SizedBox(height: 8),
- Row(children: [
- shimmerBox(16, 12),
- const SizedBox(width: 4),
- shimmerBox(120, 12)
- ]),
- ],
- ),
- ),
- );
- }
- }
- /// 带单仓位卡片骨架(对应 _OrderCard 当前带单样式)
- class _OrderCardSkeleton extends StatelessWidget {
- const _OrderCardSkeleton();
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return AppShimmer(
- child: Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color:
- isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- border: isDark
- ? null
- : Border.all(color: AppColors.lightBorder, width: 0.5),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 标题行:交易对 + badges
- Row(children: [
- shimmerBox(80, 15),
- const SizedBox(width: 8),
- shimmerBox(40, 20, radius: 4),
- const SizedBox(width: 6),
- shimmerBox(30, 20, radius: 4),
- const SizedBox(width: 6),
- shimmerBox(35, 20, radius: 4),
- ]),
- const SizedBox(height: 12),
- // 未实现盈亏
- Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(90, 11),
- const SizedBox(height: 5),
- shimmerBox(120, 22),
- ])),
- Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
- shimmerBox(45, 11),
- const SizedBox(height: 5),
- shimmerBox(70, 14),
- ]),
- ],
- ),
- const SizedBox(height: 12),
- shimmerFill(0.5),
- const SizedBox(height: 10),
- Row(
- children: List.generate(
- 3,
- (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: i == 1 ? 8.0 : 0),
- child: Column(
- crossAxisAlignment: i == 0
- ? CrossAxisAlignment.start
- : i == 1
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.end,
- children: [
- shimmerBox(70, 11),
- const SizedBox(height: 4),
- shimmerBox(50, 13)
- ],
- ),
- ),
- ))),
- const SizedBox(height: 10),
- Row(
- children: List.generate(
- 3,
- (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.symmetric(
- horizontal: i == 1 ? 8.0 : 0),
- child: Column(
- crossAxisAlignment: i == 0
- ? CrossAxisAlignment.start
- : i == 1
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.end,
- children: [
- shimmerBox(70, 11),
- const SizedBox(height: 4),
- shimmerBox(50, 13)
- ],
- ),
- ),
- ))),
- ],
- ),
- ),
- );
- }
- }
- class _Badge extends StatelessWidget {
- const _Badge({required this.text, required this.color});
- final String text;
- final Color color;
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
- decoration: BoxDecoration(
- color: color.withAlpha(30),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(text,
- style: TextStyle(
- color: color, fontSize: 11, fontWeight: FontWeight.w600)),
- );
- }
- }
- class _OrderStat extends StatelessWidget {
- const _OrderStat(
- {required this.label,
- required this.value,
- this.alignEnd = false,
- this.alignCenter = false});
- final String label;
- final String value;
- final bool alignEnd;
- final bool alignCenter;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final align = alignEnd
- ? CrossAxisAlignment.end
- : alignCenter
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.start;
- return Expanded(
- child: Column(
- crossAxisAlignment: align,
- children: [
- Text(label,
- style:
- TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
- const SizedBox(height: 2),
- Text(value,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w600)),
- ],
- ),
- );
- }
- }
- // ── 分享带单 BottomSheet ───────────────────────────────────
- class _ShareOrderSheet extends ConsumerStatefulWidget {
- const _ShareOrderSheet({required this.order, required this.fmt});
- final Map<String, dynamic> order;
- final String Function(dynamic, {int decimals}) fmt;
- @override
- ConsumerState<_ShareOrderSheet> createState() => _ShareOrderSheetState();
- }
- class _ShareOrderSheetState extends ConsumerState<_ShareOrderSheet> {
- final _cardKey = GlobalKey();
- bool _sharing = false;
- bool _saving = false;
- String? _inviteCode;
- String? _inviteUrl;
- @override
- void initState() {
- super.initState();
- _loadInviteInfo();
- }
- Future<void> _loadInviteInfo() async {
- try {
- final dio = ref.read(dioClientProvider);
- final data = await AuthService(dio).getMyInfo();
- final prefix = data['promotionPrefix']?.toString() ?? '';
- final code = data['promotionCode']?.toString() ?? '';
- final url =
- (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null;
- if (context.mounted) {
- setState(() {
- _inviteCode = code.isNotEmpty ? code : null;
- _inviteUrl = url;
- });
- }
- } catch (_) {}
- }
- String _fmtTimestamp(dynamic ts) {
- if (ts == null) return '--';
- final ms = int.tryParse(ts.toString());
- if (ms == null) return ts.toString();
- final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)
- .add(const Duration(hours: 8));
- return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
- '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
- }
- Future<Uint8List?> _renderCard() async {
- final boundary =
- _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
- if (boundary == null) return null;
- final image = await boundary.toImage(pixelRatio: 3.0);
- final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
- return byteData?.buffer.asUint8List();
- }
- Future<void> _doSave(BuildContext context) async {
- setState(() => _saving = true);
- try {
- final bytes = await _renderCard();
- if (bytes == null) return;
- await Gal.requestAccess();
- await Gal.putImageBytes(
- bytes,
- name: 'trade_share_${DateTime.now().millisecondsSinceEpoch}',
- );
- if (!context.mounted) return;
- showTopToast(context,
- message: AppLocalizations.of(context)!.saveSuccess,
- backgroundColor: AppColors.rise);
- } on GalException catch (e) {
- if (!context.mounted) return;
- final l10n = AppLocalizations.of(context)!;
- if (e.type == GalExceptionType.accessDenied) {
- showTopToast(context,
- message: l10n.photoPermissionDenied,
- backgroundColor: AppColors.fall);
- } else {
- showTopToast(context,
- message: l10n.saveFailed, backgroundColor: AppColors.fall);
- }
- } catch (e) {
- if (context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.saveFailed,
- backgroundColor: AppColors.fall);
- }
- } finally {
- if (context.mounted) setState(() => _saving = false);
- }
- }
- Future<void> _doShare(BuildContext context) async {
- setState(() => _sharing = true);
- try {
- final bytes = await _renderCard();
- if (bytes == null) return;
- final tmpDir = await getTemporaryDirectory();
- final file = File(
- '${tmpDir.path}/trade_share_${DateTime.now().millisecondsSinceEpoch}.png');
- await file.writeAsBytes(bytes);
- if (!context.mounted) return;
- Navigator.of(context).pop();
- await Share.shareXFiles(
- [XFile(file.path, mimeType: 'image/png')],
- subject: AppLocalizations.of(context)!.myTradingProfit,
- );
- } catch (e) {
- if (context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.shareFailed,
- backgroundColor: AppColors.fall);
- }
- } finally {
- if (context.mounted) setState(() => _sharing = false);
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final order = widget.order;
- final profit = double.tryParse(order['profit']?.toString() ?? '0') ?? 0.0;
- final pnlPositive = profit >= 0;
- final l10n = AppLocalizations.of(context)!;
- final symbol = order['symbol']?.toString() ?? '--';
- final isLong = (order['direction']?.toString() ?? '0') == '0';
- final leverage = order['leverage']?.toString() ?? '--';
- final profitRateRaw =
- double.tryParse(order['profitRate']?.toString() ?? '0') ?? 0.0;
- final profitRateStr =
- '${profitRateRaw >= 0 ? '+' : ''}${profitRateRaw.toStringAsFixed(2)}%';
- final openPrice = widget.fmt(order['openPrice']);
- final closePrice = widget.fmt(order['closePrice']);
- final openTime = _fmtTimestamp(order['openTime']);
- final closeTime = _fmtTimestamp(order['closeTime']);
- return Container(
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
- ),
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // 拖拽指示条
- Container(
- width: 36,
- height: 4,
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(60),
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- const SizedBox(height: 16),
- // 分享卡片预览
- RepaintBoundary(
- key: _cardKey,
- child: _TradeShareCard(
- symbol: symbol,
- isLong: isLong,
- leverage: leverage,
- profitRateStr: profitRateStr,
- pnlPositive: pnlPositive,
- openPrice: openPrice,
- closePrice: closePrice,
- openTime: openTime,
- closeTime: closeTime,
- inviteCode: _inviteCode,
- inviteUrl: _inviteUrl,
- ),
- ),
- const SizedBox(height: 24),
- // 操作按钮行:取消 | 保存海报 | 分享
- Row(
- children: [
- Expanded(
- child: OutlinedButton(
- onPressed: () => Navigator.of(context).pop(),
- style: OutlinedButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- ),
- child: Text(l10n.cancelLabel,
- style: TextStyle(color: cs.onSurface, fontSize: 14)),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: OutlinedButton(
- onPressed: _saving ? null : () => _doSave(context),
- style: OutlinedButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- ),
- child: _saving
- ? SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: cs.onSurface.withAlpha(153)),
- )
- : Text(l10n.savePoster,
- style: TextStyle(color: cs.onSurface, fontSize: 14)),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: ElevatedButton(
- onPressed: _sharing ? null : () => _doShare(context),
- style: ElevatedButton.styleFrom(
- backgroundColor:
- pnlPositive ? AppColors.rise : AppColors.fall,
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- elevation: 0,
- ),
- child: _sharing
- ? const SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2, color: Colors.white),
- )
- : Text(l10n.shareLabel,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- // ── 带单分享卡片内容 ─────────────────────────────────────────
- class _TradeShareCard extends StatelessWidget {
- const _TradeShareCard({
- required this.symbol,
- required this.isLong,
- required this.leverage,
- required this.profitRateStr,
- required this.pnlPositive,
- required this.openPrice,
- required this.closePrice,
- required this.openTime,
- required this.closeTime,
- this.inviteCode,
- this.inviteUrl,
- });
- final String symbol;
- final bool isLong;
- final String leverage;
- final String profitRateStr;
- final bool pnlPositive;
- final String openPrice;
- final String closePrice;
- final String openTime;
- final String closeTime;
- final String? inviteCode;
- final String? inviteUrl;
- String _baseCoin(String sym) {
- if (sym.contains('/')) return sym.split('/').first;
- return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- }
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- final sideColor = isLong ? AppColors.rise : AppColors.fall;
- final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall;
- final coinSymbol = _baseCoin(symbol);
- // 主题色变量
- final bgColors = isDark
- ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)]
- : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)];
- final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E);
- final textSecondary = isDark
- ? Colors.white.withAlpha(120)
- : const Color(0xFF1A1F2E).withAlpha(120);
- final textMuted = isDark
- ? Colors.white.withAlpha(80)
- : const Color(0xFF1A1F2E).withAlpha(80);
- final borderColor = isDark
- ? Colors.white.withAlpha(40)
- : const Color(0xFF1A1F2E).withAlpha(30);
- final qrFgColor = isDark ? Colors.white : Colors.black;
- final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white;
- return Container(
- width: double.infinity,
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- colors: bgColors,
- ),
- borderRadius: BorderRadius.circular(16),
- ),
- clipBehavior: Clip.antiAlias,
- child: Padding(
- padding: const EdgeInsets.all(20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // LOGO + 品牌名
- Row(
- children: [
- Image.asset(
- 'assets/images/app_icon.png',
- height: 28,
- width: 28,
- errorBuilder: (_, __, ___) => const SizedBox.shrink(),
- ),
- const SizedBox(width: 8),
- Text(
- 'iBit',
- style: TextStyle(
- color: textPrimary,
- fontSize: 14,
- fontWeight: FontWeight.w700,
- letterSpacing: 0.5),
- ),
- ],
- ),
- const SizedBox(height: 14),
- // 币对 + 永续 tag
- Row(
- children: [
- Text(
- '${coinSymbol}USDT',
- style: TextStyle(
- color: textPrimary,
- fontSize: 22,
- fontWeight: FontWeight.w800),
- ),
- const SizedBox(width: 8),
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
- decoration: BoxDecoration(
- color: const Color(0xFFFFAB00),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(l10n.perpetual,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.w700)),
- ),
- ],
- ),
- const SizedBox(height: 4),
- // 方向 + 杠杆
- Text(
- '${isLong ? l10n.openLong : l10n.openShort} ${leverage}X',
- style: TextStyle(
- color: sideColor, fontSize: 15, fontWeight: FontWeight.w700),
- ),
- const SizedBox(height: 14),
- // 收益率(大字)
- Text(l10n.returnRate,
- style: TextStyle(color: textSecondary, fontSize: 12)),
- const SizedBox(height: 4),
- Text(profitRateStr,
- style: TextStyle(
- color: pnlColor,
- fontSize: 36,
- fontWeight: FontWeight.w800,
- letterSpacing: -0.5)),
- const SizedBox(height: 16),
- // 开仓均价 + 平仓均价
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.openAvgPrice,
- style: TextStyle(color: textSecondary, fontSize: 11)),
- const SizedBox(height: 2),
- Text(openPrice,
- style: TextStyle(
- color: textPrimary,
- fontSize: 13,
- fontWeight: FontWeight.w600)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.avgClosePrice,
- style: TextStyle(color: textSecondary, fontSize: 11)),
- const SizedBox(height: 2),
- Text(closePrice,
- style: TextStyle(
- color: textPrimary,
- fontSize: 13,
- fontWeight: FontWeight.w600)),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: 10),
- // 时间
- Text(closeTime != '--' ? closeTime : openTime,
- style: TextStyle(color: textMuted, fontSize: 11)),
- const SizedBox(height: 14),
- // 分隔线
- Divider(color: borderColor, height: 1),
- const SizedBox(height: 14),
- // 邀请码 + 二维码
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (inviteCode != null)
- RichText(
- text: TextSpan(
- style: const TextStyle(fontSize: 15),
- children: [
- TextSpan(
- text: l10n.inviteCodeLabel,
- style: TextStyle(color: textSecondary),
- ),
- TextSpan(
- text: inviteCode!,
- style: const TextStyle(
- color: AppColors.brand,
- fontWeight: FontWeight.w700),
- ),
- ],
- ),
- ),
- const SizedBox(height: 4),
- Text(l10n.registerAndEarnRebate,
- style: TextStyle(color: textMuted, fontSize: 12)),
- ],
- ),
- ),
- Container(
- decoration: BoxDecoration(
- border: Border.all(color: borderColor, width: 1),
- borderRadius: BorderRadius.circular(6),
- ),
- padding: const EdgeInsets.all(4),
- child: inviteUrl != null
- ? QrImageView(
- data: inviteUrl!,
- version: QrVersions.auto,
- size: 80,
- eyeStyle: QrEyeStyle(
- eyeShape: QrEyeShape.square,
- color: qrFgColor,
- ),
- dataModuleStyle: QrDataModuleStyle(
- dataModuleShape: QrDataModuleShape.square,
- color: qrFgColor,
- ),
- backgroundColor: qrBgColor,
- errorCorrectionLevel: QrErrorCorrectLevel.M,
- )
- : const SizedBox(width: 80, height: 80),
- ),
- ],
- ),
- ],
- ),
- ),
- );
- }
- }
|