| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783 |
- 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/network/dio_client.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/dialog_utils.dart' show showConfirmDialog, showTipDialog, extractErrorMessage;
- import '../../../core/utils/number_format.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../widgets/common/app_refresh_indicator.dart';
- import '../../widgets/common/app_shimmer.dart';
- import '../../widgets/common/app_tab_bar.dart';
- import '../../../data/models/copy_trading/copy_position.dart';
- import '../../../data/models/copy_trading/trader.dart';
- import '../../../data/repositories/copy_trading_repository.dart';
- import '../../../data/services/auth_service.dart';
- import '../../../providers/my_copy_trading_provider.dart';
- class MyCopyTradingScreen extends ConsumerStatefulWidget {
- const MyCopyTradingScreen({super.key});
- @override
- ConsumerState<MyCopyTradingScreen> createState() => _MyCopyTradingScreenState();
- }
- class _MyCopyTradingScreenState extends ConsumerState<MyCopyTradingScreen>
- with SingleTickerProviderStateMixin {
- late TabController _tabController;
- late PageController _pageController;
- final _currentScrollCtrl = ScrollController();
- final _tradersScrollCtrl = ScrollController();
- final _historyScrollCtrl = ScrollController();
- @override
- void initState() {
- super.initState();
- _tabController = TabController(length: 3, vsync: this);
- _pageController = PageController();
- // 每次进入页面都重置 tab 并刷新数据
- WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(myCopyTradingProvider.notifier).setTab(0);
- ref.read(myCopyTradingProvider.notifier).refresh();
- });
- _tabController.addListener(() {
- if (!mounted) return;
- if (_tabController.indexIsChanging) {
- _pageController.animateToPage(
- _tabController.index,
- duration: const Duration(milliseconds: 280),
- curve: Curves.easeOut,
- );
- } else {
- ref.read(myCopyTradingProvider.notifier).setTab(_tabController.index);
- }
- });
- _pageController.addListener(() {
- if (!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);
- }
- });
- _currentScrollCtrl.addListener(() {
- if (!mounted) return;
- if (_currentScrollCtrl.position.pixels >= _currentScrollCtrl.position.maxScrollExtent - 200) {
- ref.read(myCopyTradingProvider.notifier).loadMoreCurrent();
- }
- });
- _tradersScrollCtrl.addListener(() {
- if (!mounted) return;
- if (_tradersScrollCtrl.position.pixels >= _tradersScrollCtrl.position.maxScrollExtent - 200) {
- ref.read(myCopyTradingProvider.notifier).loadMoreTraders();
- }
- });
- _historyScrollCtrl.addListener(() {
- if (!mounted) return;
- if (_historyScrollCtrl.position.pixels >= _historyScrollCtrl.position.maxScrollExtent - 200) {
- ref.read(myCopyTradingProvider.notifier).loadMoreHistory();
- }
- });
- }
- @override
- void dispose() {
- _tabController.dispose();
- _pageController.dispose();
- _currentScrollCtrl.dispose();
- _tradersScrollCtrl.dispose();
- _historyScrollCtrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
- final state = ref.watch(myCopyTradingProvider);
- final currentCount = state.currentPositions.length;
- final traderCount = state.myTraders.length;
- return Scaffold(
- backgroundColor: cardBg,
- appBar: AppBar(
- leading: IconButton(
- icon: const Icon(Icons.arrow_back_ios, size: 18),
- onPressed: () => context.pop(),
- ),
- title: Text(AppLocalizations.of(context)!.myCopyTrading, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
- ),
- body: state.isLoading
- ? _MyCopyTradingFullSkeleton(cardBg: cardBg)
- : Column(
- children: [
- // 统计卡片(带眼睛隐藏)—— 铺满白色背景
- if (state.account != null)
- _StatsCard(
- account: state.account!,
- onTransfer: () async {
- await context.push('/asset/transfer?from=SPOT&to=FOLLOW');
- if (context.mounted) {
- ref.read(myCopyTradingProvider.notifier).refresh();
- }
- },
- ),
- // 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,
- labelColor: AppColors.brand,
- unselectedLabelColor: cs.onSurface.withAlpha(153),
- labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
- unselectedLabelStyle: const TextStyle(fontSize: 14),
- tabs: [
- Tab(text: currentCount > 0 ? '${AppLocalizations.of(context)!.currentFollowOrders}($currentCount)' : AppLocalizations.of(context)!.currentFollowOrders),
- Tab(text: traderCount > 0 ? '${AppLocalizations.of(context)!.myTraders}($traderCount)' : AppLocalizations.of(context)!.myTraders),
- Tab(text: AppLocalizations.of(context)!.historyFollowOrders),
- ],
- ),
- ),
- // 内容区
- Expanded(
- child: PageView(
- controller: _pageController,
- physics: const BouncingScrollPhysics(
- parent: AlwaysScrollableScrollPhysics(),
- ),
- onPageChanged: (index) {
- if (_tabController.indexIsChanging) return;
- _tabController.index = index;
- },
- children: [
- _buildCurrentTab(state),
- _buildTradersTab(state),
- _buildHistoryTab(state),
- ],
- ),
- ),
- ],
- ),
- );
- }
- Widget _buildCurrentTab(MyCopyTradingState state) {
- final onRefresh = () => ref.read(myCopyTradingProvider.notifier).silentRefresh();
- if (state.isLoading && state.currentPositions.isEmpty) {
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: 3,
- itemBuilder: (_, __) => const _PositionCardSkeleton(),
- );
- }
- return AppRefreshIndicator(
- onRefresh: onRefresh,
- child: state.currentPositions.isEmpty
- ? ListView(
- physics: const AlwaysScrollableScrollPhysics(),
- children: [_EmptyState(onGoMarket: () => context.pop())],
- )
- : ListView.builder(
- controller: _currentScrollCtrl,
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: state.currentPositions.length + 1,
- itemBuilder: (_, i) {
- if (i >= state.currentPositions.length) {
- return _loadMoreFooter(state.currentLoadingMore, state.currentHasMore);
- }
- return _PositionCard(
- position: state.currentPositions[i],
- isHistory: false,
- onClose: (id) async {
- final confirmed = await showConfirmDialog(
- context,
- content: AppLocalizations.of(context)!.closePositionConfirmMsg,
- );
- if (!confirmed || !context.mounted) return;
- try {
- await ref.read(myCopyTradingProvider.notifier).closePosition(id);
- if (context.mounted) {
- showTopToast(context, message: AppLocalizations.of(context)!.closePositionSuccess, backgroundColor: AppColors.rise);
- }
- } catch (e) {
- if (context.mounted) {
- showTopToast(context, message: extractErrorMessage(e), backgroundColor: AppColors.fall);
- }
- }
- },
- );
- },
- ),
- );
- }
- Widget _buildTradersTab(MyCopyTradingState state) {
- final onRefresh = () => ref.read(myCopyTradingProvider.notifier).silentRefresh();
- if (state.isLoading && state.myTraders.isEmpty) {
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: 3,
- itemBuilder: (_, __) => const _MyTraderCardSkeleton(),
- );
- }
- return AppRefreshIndicator(
- onRefresh: onRefresh,
- child: state.myTraders.isEmpty
- ? ListView(
- physics: const AlwaysScrollableScrollPhysics(),
- children: [_EmptyState(onGoMarket: () => context.pop())],
- )
- : ListView.builder(
- controller: _tradersScrollCtrl,
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: state.myTraders.length + 1,
- itemBuilder: (_, i) {
- if (i >= state.myTraders.length) {
- return _loadMoreFooter(state.tradersLoadingMore, state.tradersHasMore);
- }
- return _MyTraderCard(
- trader: state.myTraders[i],
- onUnfollow: () async {
- final confirmed = await showConfirmDialog(
- context,
- content: AppLocalizations.of(context)!.unfollowTraderConfirm,
- );
- if (!confirmed || !context.mounted) return;
- try {
- await ref.read(myCopyTradingProvider.notifier).unfollowTrader(state.myTraders[i].id);
- } catch (e) {
- if (context.mounted) showTipDialog(context, content: extractErrorMessage(e));
- }
- },
- );
- },
- ),
- );
- }
- Widget _buildHistoryTab(MyCopyTradingState state) {
- final onRefresh = () => ref.read(myCopyTradingProvider.notifier).silentRefresh();
- if (state.isLoading && state.historyPositions.isEmpty) {
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: 3,
- itemBuilder: (_, __) => const _PositionCardSkeleton(),
- );
- }
- return AppRefreshIndicator(
- onRefresh: onRefresh,
- child: state.historyPositions.isEmpty
- ? ListView(
- physics: const AlwaysScrollableScrollPhysics(),
- children: [_EmptyState(onGoMarket: () => context.pop())],
- )
- : ListView.builder(
- controller: _historyScrollCtrl,
- physics: const AlwaysScrollableScrollPhysics(),
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: state.historyPositions.length + 1,
- itemBuilder: (_, i) {
- if (i >= state.historyPositions.length) {
- return _loadMoreFooter(state.historyLoadingMore, state.historyHasMore);
- }
- return _PositionCard(
- position: state.historyPositions[i],
- isHistory: true,
- );
- },
- ),
- );
- }
- Widget _loadMoreFooter(bool loading, bool hasMore) {
- if (loading) {
- 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: Theme.of(context).colorScheme.onSurface.withAlpha(100), fontSize: 12)),
- ),
- );
- }
- return const SizedBox(height: 8);
- }
- }
- // ── 统计卡片(眼睛隐藏) ───────────────────────────────────
- class _StatsCard extends StatefulWidget {
- const _StatsCard({required this.account, this.onTransfer});
- final dynamic account;
- final VoidCallback? onTransfer;
- @override
- State<_StatsCard> createState() => _StatsCardState();
- }
- class _StatsCardState extends State<_StatsCard> {
- bool _visible = true;
- /// 截断到2位小数,不四舍五入(等同 Java RoundingMode.DOWN)
- String _fmtDown(double v) {
- final isNeg = v < 0;
- final abs = v.abs();
- final str = abs.toStringAsFixed(10);
- final dotIdx = str.indexOf('.');
- final intPart = dotIdx < 0 ? str : str.substring(0, dotIdx);
- final fracPart = dotIdx < 0 ? '00' : (str.substring(dotIdx + 1) + '00').substring(0, 2);
- return '${isNeg ? '-' : ''}$intPart.$fracPart';
- }
- @override
- Widget build(BuildContext context) {
- final acc = widget.account;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final cs = Theme.of(context).colorScheme;
- final pnlValue = acc.unrealizedPnl as double;
- // 黄色背景上用深绿色以保证对比度
- final pnlColor = pnlValue >= 0 ? const Color(0xFF1A7A4A) : AppColors.fall;
- final pnlSign = pnlValue >= 0 ? '+' : '';
- const iconColor = Colors.black54;
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 12, 16, 4),
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(16),
- ),
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 眼睛 + 划转
- Row(
- mainAxisAlignment: MainAxisAlignment.end,
- children: [
- GestureDetector(
- onTap: () => setState(() => _visible = !_visible),
- child: Icon(
- _visible ? Icons.visibility_outlined : Icons.visibility_off_outlined,
- size: 18,
- color: iconColor,
- ),
- ),
- const SizedBox(width: 12),
- GestureDetector(
- onTap: widget.onTransfer ?? () => context.push('/asset/transfer?from=SPOT&to=FOLLOW'),
- child: const Icon(Icons.sync_alt, size: 18, color: iconColor),
- ),
- ],
- ),
- const SizedBox(height: 8),
- // 三列数据
- Builder(builder: (context) {
- final l10n = AppLocalizations.of(context)!;
- return Row(
- children: [
- _StatCol(
- label: l10n.cumCopyProfitUsdt,
- value: _visible ? _fmtDown(acc.cumulativePnl as double) : '****',
- ),
- _StatCol(
- label: l10n.availableBalanceUsdt,
- value: _visible ? _fmtDown(acc.availableBalance as double) : '****',
- ),
- _StatCol(
- label: l10n.unrealizedPnlUsdt,
- value: _visible
- ? '$pnlSign${_fmtDown(pnlValue.abs())}'
- : '****',
- valueColor: _visible ? pnlColor : null,
- ),
- ],
- );
- }),
- ],
- ),
- ),
- );
- }
- }
- class _StatCol extends StatelessWidget {
- const _StatCol({required this.label, required this.value, this.valueColor});
- final String label;
- final String value;
- final Color? valueColor;
- @override
- Widget build(BuildContext context) {
- return Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- label,
- style: TextStyle(color: Colors.black.withAlpha(140), fontSize: 11),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- const SizedBox(height: 5),
- Text(
- value,
- style: TextStyle(
- color: valueColor ?? Colors.black,
- fontSize: 16,
- fontWeight: FontWeight.w700,
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- ],
- ),
- );
- }
- }
- // ── 仓位卡片 ────────────────────────────────────────────────
- class _PositionCard extends StatelessWidget {
- const _PositionCard({
- required this.position,
- required this.isHistory,
- this.onClose,
- });
- final CopyPosition position;
- final bool isHistory;
- final void Function(String positionId)? onClose;
- static const _avatarColors = [
- Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
- Color(0xFFf3ba2f), Color(0xFF2775ca),
- ];
- Color get _avatarBg =>
- _avatarColors[position.traderName.codeUnitAt(0) % _avatarColors.length];
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- final isLong = position.isLong;
- final directionColor = isLong ? AppColors.rise : AppColors.fall;
- final directionLabel = isLong ? l10n.openLongBullish : l10n.openShortBearish;
- final pnlColor = position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall;
- final pnlSign = position.unrealizedPnl >= 0 ? '+' : '';
- final roiSign = position.roi >= 0 ? '+' : '';
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- borderRadius: BorderRadius.circular(16),
- boxShadow: [
- BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (isHistory)
- // 历史跟单:品种在左,交易员头像+名称在右,分享按钮最右
- Row(
- children: [
- Expanded(
- child: Text(
- position.symbol,
- style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
- ),
- ),
- _TraderAvatar(
- name: position.traderName,
- avatarUrl: position.traderAvatar,
- bgColor: _avatarBg,
- size: 32,
- ),
- const SizedBox(width: 8),
- Text(position.traderName,
- style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)),
- const SizedBox(width: 10),
- GestureDetector(
- onTap: () => _showShareSheet(context),
- child: Icon(Icons.share_outlined, size: 18, color: cs.onSurface.withAlpha(120)),
- ),
- ],
- )
- else
- // 当前跟单:交易员头像+名称在左,平仓按钮在右
- Row(
- children: [
- _TraderAvatar(
- name: position.traderName,
- avatarUrl: position.traderAvatar,
- bgColor: _avatarBg,
- size: 38,
- ),
- const SizedBox(width: 10),
- Expanded(
- child: Text(
- position.traderName,
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600),
- ),
- ),
- ElevatedButton(
- onPressed: () => onClose?.call(position.id),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- foregroundColor: Colors.black,
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
- minimumSize: Size.zero,
- tapTargetSize: MaterialTapTargetSize.shrinkWrap,
- elevation: 0,
- ),
- child: Text(l10n.closePosition, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
- ),
- ],
- ),
- const SizedBox(height: 10),
- // 品种行(当前跟单时显示)
- if (!isHistory)
- Wrap(
- spacing: 6,
- runSpacing: 6,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- Text(position.symbol,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)),
- _Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
- _Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- _Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- _Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
- ],
- )
- else
- // 历史跟单:direction + 永续 + positionType + leverage
- Wrap(
- spacing: 6,
- runSpacing: 6,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- _Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
- _Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- _Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- _Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
- ],
- ),
- const SizedBox(height: 10),
- if (!isHistory) ...[
- // 当前跟单数据行
- Row(
- children: [
- _DataCell(label: l10n.openAvgPriceUsdt, value: position.openPrice.toStringAsFixed(1)),
- _DataCell(label: l10n.currentPriceUsdt, value: position.currentPrice.toStringAsFixed(1)),
- _DataCell(label: l10n.currentMarginUsdt, value: position.margin.toStringAsFixed(2)),
- ],
- ),
- const SizedBox(height: 8),
- Row(
- children: [
- _DataCell(label: l10n.qtyWithCoin(_baseAsset(position.symbol)), value: position.quantity.toStringAsFixed(4)),
- _DataCell(label: l10n.returnRate, value: '$roiSign${position.roi.toStringAsFixed(2)}%', valueColor: pnlColor),
- _DataCell(label: l10n.profitUsdt, value: '$pnlSign${position.unrealizedPnl.toStringAsFixed(3)}', valueColor: pnlColor),
- ],
- ),
- ] else ...[
- // 历史跟单数据行:数量 / 收益 / 收益率(三列,最后列右对齐)
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.qtyWithCoin(_baseAsset(position.symbol)),
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 4),
- Text(position.quantity.toStringAsFixed(4),
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.profitUsdt,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 4),
- Text('$pnlSign${position.unrealizedPnl.toStringAsFixed(4)}',
- style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.returnRate,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 4),
- Text('$roiSign${position.roi.toStringAsFixed(2)}%',
- style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
- Divider(height: 1, thickness: 1, color: cs.outlineVariant.withAlpha(60)),
- const SizedBox(height: 12),
- // 底部:开仓均价(左) / 平仓均价(右对齐)
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.openAvgPriceUsdt,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 3),
- Text(position.openPrice.toStringAsFixed(1),
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
- const SizedBox(height: 2),
- Text(_formatDate(position.openTime),
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.closeAvgPriceUsdt,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 3),
- Text(
- position.closePrice != null ? position.closePrice!.toStringAsFixed(1) : '--',
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700),
- ),
- const SizedBox(height: 2),
- Text(
- position.closeTime != null ? _formatDate(position.closeTime!) : '--',
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
- ),
- ],
- ),
- ],
- ),
- ],
- if (!isHistory) ...[
- const SizedBox(height: 10),
- Row(
- children: [
- Text(
- l10n.openTimeWithValue(_formatDate(position.openTime)),
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11),
- ),
- const Spacer(),
- Text('${l10n.positionIdPrefix}${position.id}',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(width: 4),
- GestureDetector(
- onTap: () {
- Clipboard.setData(ClipboardData(text: position.id));
- showTopToast(context, message: l10n.copyPositionIdSuccess, backgroundColor: AppColors.rise);
- },
- child: Icon(Icons.copy_outlined, size: 14, color: cs.onSurface.withAlpha(153)),
- ),
- ],
- ),
- ],
- ],
- ),
- );
- }
- void _showShareSheet(BuildContext context) {
- showModalBottomSheet(
- context: context,
- useRootNavigator: true,
- backgroundColor: Colors.transparent,
- isScrollControlled: true,
- builder: (_) => _FollowShareSheet(position: position),
- );
- }
- String _baseAsset(String symbol) {
- final s = symbol.replaceAll(' 永续', '');
- final parts = s.split('/');
- return parts.isNotEmpty ? parts[0] : s;
- }
- String _formatDate(DateTime dt) =>
- '${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')}';
- }
- // ── 历史跟单分享 BottomSheet ───────────────────────────────
- class _FollowShareSheet extends ConsumerStatefulWidget {
- const _FollowShareSheet({required this.position});
- final CopyPosition position;
- @override
- ConsumerState<_FollowShareSheet> createState() => _FollowShareSheetState();
- }
- class _FollowShareSheetState extends ConsumerState<_FollowShareSheet> {
- 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 (mounted) {
- setState(() {
- _inviteCode = code.isNotEmpty ? code : null;
- _inviteUrl = url;
- });
- }
- } catch (_) {}
- }
- 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: 'follow_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 (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}/follow_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)!.myCopyTradingProfit,
- );
- } catch (e) {
- if (context.mounted) {
- showTopToast(context, message: AppLocalizations.of(context)!.shareFailed, backgroundColor: AppColors.fall);
- }
- } finally {
- if (mounted) setState(() => _sharing = false);
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final p = widget.position;
- final pnlPositive = p.unrealizedPnl >= 0;
- final l10n = AppLocalizations.of(context)!;
- 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: _FollowShareCard(
- position: p,
- 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 _FollowShareCard extends StatelessWidget {
- const _FollowShareCard({
- required this.position,
- this.inviteCode,
- this.inviteUrl,
- });
- final CopyPosition position;
- final String? inviteCode;
- final String? inviteUrl;
- String _baseCoin(String sym) {
- if (sym.contains('/')) return sym.split('/').first;
- return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- }
- String _fmtDate(DateTime dt) {
- 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 '${dt.year}-$mo-$d $h:$mi:$s';
- }
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- final p = position;
- final isLong = p.isLong;
- final sideColor = isLong ? AppColors.rise : AppColors.fall;
- final pnlPositive = p.unrealizedPnl >= 0;
- final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall;
- final coinSymbol = _baseCoin(p.symbol);
- final roiStr = '${pnlPositive ? '+' : ''}${formatAmount(p.roi)}%';
- final closePrice = p.closePrice;
- final closeTime = p.closeTime;
- // 主题色变量
- 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} ${p.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(roiStr,
- style: TextStyle(
- color: pnlColor,
- fontSize: 36,
- fontWeight: FontWeight.w800,
- letterSpacing: -0.5)),
- const SizedBox(height: 16),
- // 开仓均价 + 平仓均价
- Row(
- children: [
- Expanded(
- child: _FollowShareDataItem(
- label: l10n.openAvgPrice,
- value: formatAmount(p.openPrice),
- textPrimary: textPrimary,
- textSecondary: textSecondary,
- ),
- ),
- Expanded(
- child: _FollowShareDataItem(
- label: l10n.avgClosePrice,
- value: closePrice != null ? formatAmount(closePrice) : '--',
- align: CrossAxisAlignment.end,
- textPrimary: textPrimary,
- textSecondary: textSecondary,
- ),
- ),
- ],
- ),
- const SizedBox(height: 10),
- // 时间
- Text(closeTime != null ? _fmtDate(closeTime) : _fmtDate(p.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),
- ),
- ],
- ),
- ],
- ),
- ),
- );
- }
- }
- class _FollowShareDataItem extends StatelessWidget {
- const _FollowShareDataItem({
- required this.label,
- required this.value,
- required this.textPrimary,
- required this.textSecondary,
- this.align = CrossAxisAlignment.start,
- });
- final String label;
- final String value;
- final Color textPrimary;
- final Color textSecondary;
- final CrossAxisAlignment align;
- @override
- Widget build(BuildContext context) {
- return Column(
- crossAxisAlignment: align,
- children: [
- Text(label, style: TextStyle(color: textSecondary, fontSize: 11)),
- const SizedBox(height: 2),
- Text(value,
- style: TextStyle(
- color: textPrimary, fontSize: 13, fontWeight: FontWeight.w600)),
- ],
- );
- }
- }
- // ── 交易员头像 ────────────────────────────────────────────
- class _TraderAvatar extends StatelessWidget {
- const _TraderAvatar({
- required this.name,
- required this.bgColor,
- required this.size,
- this.avatarUrl,
- });
- final String name;
- final String? avatarUrl;
- final Color bgColor;
- final double size;
- @override
- Widget build(BuildContext context) {
- final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
- if (avatarUrl != null && avatarUrl!.isNotEmpty) {
- return ClipOval(
- child: Image.network(
- avatarUrl!,
- width: size,
- height: size,
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => _fallback(letter),
- ),
- );
- }
- return _fallback(letter);
- }
- Widget _fallback(String letter) => Container(
- width: size,
- height: size,
- decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle),
- child: Center(
- child: Text(letter,
- style: TextStyle(
- color: Colors.white,
- fontSize: size * 0.42,
- fontWeight: FontWeight.w700)),
- ),
- );
- }
- // ── 我的交易员卡片(原型风格) ─────────────────────────────
- class _MyTraderCard extends StatelessWidget {
- const _MyTraderCard({required this.trader, required this.onUnfollow});
- final Trader trader;
- final VoidCallback onUnfollow;
- static const _avatarColors = [
- Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
- Color(0xFFf3ba2f), Color(0xFF2775ca),
- ];
- Color get _bg =>
- _avatarColors[trader.avatarLetter.codeUnitAt(0) % _avatarColors.length];
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return 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),
- 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: [
- // 头像 + 等级角标
- SizedBox(
- width: 52,
- height: 60,
- child: Stack(
- clipBehavior: Clip.none,
- children: [
- _TraderAvatar(
- name: trader.name,
- avatarUrl: trader.avatarUrl,
- bgColor: _bg,
- size: 52,
- ),
- if (trader.levelName != null && trader.levelName!.isNotEmpty)
- Positioned(
- bottom: 0,
- left: 0,
- right: 0,
- child: Center(
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
- decoration: BoxDecoration(
- color: AppColors.darkBadgeBg,
- borderRadius: BorderRadius.circular(20),
- border: Border.all(color: AppColors.darkBgMid),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Icon(Icons.link, size: 8, color: AppColors.rankPurple),
- const SizedBox(width: 2),
- Text(trader.levelName!,
- style: const TextStyle(
- color: AppColors.rankPurple,
- fontSize: 9,
- fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- const SizedBox(width: 12),
- // 名称 + 描述
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(trader.name,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w600)),
- if (trader.description != null && trader.description!.isNotEmpty) ...[
- const SizedBox(height: 2),
- Text(trader.description!,
- style: TextStyle(color: cs.onSurface.withAlpha(130), fontSize: 12),
- maxLines: 1,
- overflow: TextOverflow.ellipsis),
- ],
- if (trader.tags.isNotEmpty) ...[
- const SizedBox(height: 6),
- Wrap(
- spacing: 6,
- runSpacing: 4,
- children: trader.tags
- .map((tag) => Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
- decoration: BoxDecoration(
- color: isDark ? AppColors.tagIndigoBgDark : AppColors.tagIndigoBgLight,
- borderRadius: BorderRadius.circular(20),
- border: Border.all(color: AppColors.tagIndigo.withAlpha(80), width: 0.8),
- ),
- child: Text(tag,
- style: const TextStyle(
- color: AppColors.tagIndigo,
- fontSize: 11,
- fontWeight: FontWeight.w500)),
- ))
- .toList(),
- ),
- ],
- ],
- ),
- ),
- const SizedBox(width: 8),
- // 取消跟随按钮
- OutlinedButton(
- onPressed: onUnfollow,
- style: OutlinedButton.styleFrom(
- backgroundColor: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- 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)!.unfollow, 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: [
- _TraderStatCol(
- label: l10n.profitUsdt,
- value: trader.profitAmount == 0
- ? '--'
- : '${trader.profitAmount >= 0 ? '+' : ''}${trader.profitAmount.toStringAsFixed(2)}',
- valueColor: trader.profitAmount >= 0 ? AppColors.rise : AppColors.fall,
- ),
- VerticalDivider(width: 1, thickness: 0.8, color: Colors.grey.withAlpha(100)),
- _TraderStatCol(
- label: l10n.cumFollowerCount,
- value: trader.followCustomer == 0 ? '--' : '${trader.followCustomer}',
- center: true,
- ),
- VerticalDivider(width: 1, thickness: 0.8, color: Colors.grey.withAlpha(100)),
- _TraderStatCol(
- label: l10n.cumTradingDays,
- value: trader.tradingDays == 0 ? '--' : '${trader.tradingDays}',
- alignEnd: true,
- ),
- ],
- ),
- );
- }),
- ],
- ),
- );
- }
- }
- class _TraderStatCol extends StatelessWidget {
- const _TraderStatCol({
- required this.label,
- required this.value,
- this.valueColor,
- this.center = false,
- this.alignEnd = false,
- });
- final String label;
- final String value;
- final Color? valueColor;
- final bool center;
- final bool alignEnd;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final align = alignEnd
- ? CrossAxisAlignment.end
- : center
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.start;
- return Expanded(
- child: Column(
- crossAxisAlignment: align,
- children: [
- Text(label, style: TextStyle(color: cs.onSurface.withAlpha(130), fontSize: 11)),
- const SizedBox(height: 3),
- Text(value,
- style: TextStyle(
- color: valueColor ?? cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w700)),
- ],
- ),
- );
- }
- }
- // ── Badge / DataCell ──────────────────────────────────────
- class _Badge extends StatelessWidget {
- const _Badge({required this.text, required this.bgColor, required this.textColor, this.borderColor});
- final String text;
- final Color bgColor;
- final Color textColor;
- final Color? borderColor;
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(4),
- border: borderColor != null ? Border.all(color: borderColor!, width: 0.8) : null,
- ),
- child: Text(text, style: TextStyle(color: textColor, fontSize: 11, fontWeight: FontWeight.w500)),
- );
- }
- }
- class _DataCell extends StatelessWidget {
- const _DataCell({required this.label, required this.value, this.valueColor});
- final String label;
- final String value;
- final Color? valueColor;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 2),
- Text(value,
- style: TextStyle(
- color: valueColor ?? cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w500)),
- ],
- ),
- );
- }
- }
- // ── 骨架屏 ────────────────────────────────────────────────
- /// 「我的跟单」首次加载时的全页骨架
- class _MyCopyTradingFullSkeleton extends StatelessWidget {
- const _MyCopyTradingFullSkeleton({required this.cardBg});
- final Color cardBg;
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final cs = Theme.of(context).colorScheme;
- return Column(
- children: [
- // 统计卡片骨架
- AppShimmer(
- child: Container(
- margin: const EdgeInsets.fromLTRB(16, 12, 16, 4),
- padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(16),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.end,
- children: [shimmerBox(18, 18), const SizedBox(width: 10), shimmerBox(18, 18)],
- ),
- const SizedBox(height: 8),
- Row(
- children: List.generate(3, (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.only(right: i < 2 ? 12 : 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(70, 10),
- const SizedBox(height: 6),
- shimmerBox(55, 16),
- ],
- ),
- ),
- )),
- ),
- ],
- ),
- ),
- ),
- // Tab 骨架
- Container(
- color: cardBg,
- child: AppShimmer(
- 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),
- ),
- )),
- ),
- ),
- ),
- ),
- Divider(height: 1, thickness: 0.5, color: cs.outlineVariant.withAlpha(80)),
- // 列表骨架
- Expanded(
- child: ListView.builder(
- padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
- itemCount: 4,
- itemBuilder: (_, __) => const _PositionCardSkeleton(),
- ),
- ),
- ],
- );
- }
- }
- /// 仓位卡片骨架(对应 _PositionCard 当前跟单样式)
- class _PositionCardSkeleton extends StatelessWidget {
- const _PositionCardSkeleton();
- @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(16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- borderRadius: BorderRadius.circular(16),
- boxShadow: [
- BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 交易员头部行
- Row(
- children: [
- shimmerCircle(38),
- const SizedBox(width: 10),
- Expanded(child: shimmerBox(100, 15)),
- shimmerBox(60, 32, radius: 8),
- ],
- ),
- const SizedBox(height: 10),
- // 品种行
- Row(children: [
- shimmerBox(80, 14),
- const SizedBox(width: 8),
- shimmerBox(60, 20, radius: 4),
- const SizedBox(width: 6),
- shimmerBox(30, 20, radius: 4),
- const SizedBox(width: 6),
- shimmerBox(36, 20, radius: 4),
- ]),
- const SizedBox(height: 10),
- // 数据行 1
- Row(children: List.generate(3, (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
- child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
- shimmerBox(70, 11),
- const SizedBox(height: 4),
- shimmerBox(55, 13),
- ]),
- ),
- ))),
- const SizedBox(height: 8),
- // 数据行 2
- Row(children: List.generate(3, (i) => Expanded(
- child: Padding(
- padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
- child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
- shimmerBox(70, 11),
- const SizedBox(height: 4),
- shimmerBox(55, 13),
- ]),
- ),
- ))),
- ],
- ),
- ),
- );
- }
- }
- /// 我的交易员卡片骨架(对应 _MyTraderCard 样式)
- class _MyTraderCardSkeleton extends StatelessWidget {
- const _MyTraderCardSkeleton();
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final cs = Theme.of(context).colorScheme;
- 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),
- 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: [
- shimmerCircle(52),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(110, 15),
- const SizedBox(height: 6),
- shimmerBox(160, 12),
- const SizedBox(height: 8),
- Row(children: [shimmerBox(55, 22, radius: 20), const SizedBox(width: 6), shimmerBox(45, 22, radius: 20)]),
- ],
- ),
- ),
- shimmerBox(72, 30, radius: 20),
- ],
- ),
- const SizedBox(height: 12),
- Divider(height: 1, thickness: 0.5, color: cs.outlineVariant.withAlpha(80)),
- 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),
- ],
- ),
- ),
- )),
- ),
- ],
- ),
- ),
- );
- }
- }
- // ── 空状态 ────────────────────────────────────────────────
- class _EmptyState extends StatelessWidget {
- const _EmptyState({this.onGoMarket});
- final VoidCallback? onGoMarket;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.people_alt_outlined, size: 64, color: cs.onSurface.withAlpha(80)),
- const SizedBox(height: 16),
- Text(AppLocalizations.of(context)!.noData, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 15)),
- ],
- ),
- );
- }
- }
|