asset_copy_trading_tab.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../../core/l10n/app_localizations.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/utils/dialog_utils.dart' show showConfirmDialog, extractErrorMessage;
  7. import '../../../core/utils/number_format.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../providers/asset_provider.dart';
  10. import '../../../providers/my_copy_trading_provider.dart';
  11. import '../../widgets/common/app_shimmer.dart';
  12. import '../../widgets/common/app_refresh_indicator.dart';
  13. import '../../widgets/copy_position_card.dart';
  14. /// 跟单 Tab
  15. class AssetCopyTradingTab extends ConsumerWidget {
  16. const AssetCopyTradingTab({super.key, required this.state, required this.notifier});
  17. final AssetState state;
  18. final AssetNotifier notifier;
  19. @override
  20. Widget build(BuildContext context, WidgetRef ref) {
  21. final cs = Theme.of(context).colorScheme;
  22. final obscure = state.obscureBalance;
  23. final followBalance = state.walletBalance('FOLLOW').toDouble();
  24. final display = obscure ? '******' : formatPrice(followBalance, decimalPlaces: 2);
  25. // 未实现盈亏:累加所有当前跟单仓位的收益
  26. final copyState = ref.watch(myCopyTradingProvider);
  27. final followPnl = copyState.currentPositions.fold(0.0, (sum, p) => sum + p.unrealizedPnl);
  28. return AppRefreshIndicator(
  29. onRefresh: () => Future.wait([
  30. notifier.silentRefresh(),
  31. ref.read(myCopyTradingProvider.notifier).silentRefresh(),
  32. ]),
  33. child: ListView(
  34. children: [
  35. // 跟单账户余额
  36. Padding(
  37. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  38. child: Column(
  39. crossAxisAlignment: CrossAxisAlignment.start,
  40. children: [
  41. Row(
  42. children: [
  43. Text(AppLocalizations.of(context)!.copyAccount, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  44. const SizedBox(width: 6),
  45. GestureDetector(
  46. onTap: notifier.toggleObscure,
  47. child: Icon(
  48. obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  49. size: 16, color: cs.onSurface.withAlpha(153),
  50. ),
  51. ),
  52. ],
  53. ),
  54. const SizedBox(height: 8),
  55. Row(
  56. crossAxisAlignment: CrossAxisAlignment.end,
  57. children: [
  58. Text(display, style: TextStyle(color: cs.onSurface, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
  59. const SizedBox(width: 6),
  60. Padding(
  61. padding: const EdgeInsets.only(bottom: 5),
  62. child: Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  63. ),
  64. ],
  65. ),
  66. const SizedBox(height: 12),
  67. // 跟单余额
  68. Row(
  69. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  70. children: [
  71. Text(AppLocalizations.of(context)!.copyBalance, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  72. Text(
  73. obscure ? '******' : formatPrice(state.accountBalance('FOLLOW').toDouble(), decimalPlaces: 2),
  74. style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
  75. ),
  76. ],
  77. ),
  78. const SizedBox(height: 8),
  79. // 未实现盈亏:从当前跟单仓位累加
  80. Row(
  81. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  82. children: [
  83. Text(AppLocalizations.of(context)!.unrealizedPnl, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  84. Text(
  85. obscure ? '******' : '${followPnl >= 0 ? '+' : ''}${formatPrice(followPnl, decimalPlaces: 2)}',
  86. style: TextStyle(
  87. color: followPnl > 0 ? AppColors.rise : followPnl < 0 ? AppColors.fall : cs.onSurface,
  88. fontSize: 13,
  89. fontWeight: FontWeight.w500,
  90. ),
  91. ),
  92. ],
  93. ),
  94. ],
  95. ),
  96. ),
  97. // 跟单 + 划转 按钮
  98. Padding(
  99. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  100. child: Row(
  101. children: [
  102. Expanded(
  103. child: GestureDetector(
  104. onTap: () => context.go('/copy-trading'),
  105. child: Container(
  106. height: 44,
  107. decoration: BoxDecoration(color: AppColors.brand, borderRadius: BorderRadius.circular(22)),
  108. child: Center(child: Text(AppLocalizations.of(context)!.copyTrading, style: const TextStyle(color: Colors.black, fontSize: 15, fontWeight: FontWeight.w600))),
  109. ),
  110. ),
  111. ),
  112. const SizedBox(width: 12),
  113. Expanded(
  114. child: GestureDetector(
  115. onTap: () => context.push('/asset/transfer?from=SPOT&to=FOLLOW'),
  116. child: Container(
  117. height: 44,
  118. decoration: BoxDecoration(color: AppColors.brand, borderRadius: BorderRadius.circular(22)),
  119. child: Center(child: Text(AppLocalizations.of(context)!.transfer, style: const TextStyle(color: Colors.black, fontSize: 15, fontWeight: FontWeight.w600))),
  120. ),
  121. ),
  122. ),
  123. ],
  124. ),
  125. ),
  126. const SizedBox(height: 24),
  127. // 当前跟单标题
  128. Padding(
  129. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  130. child: Text(AppLocalizations.of(context)!.currentCopyTrading, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  131. ),
  132. // 跟单持仓卡片(加载中显示骨架)
  133. _buildPositions(ref, context),
  134. const SizedBox(height: 32),
  135. ],
  136. ),
  137. );
  138. }
  139. Widget _buildPositions(WidgetRef ref, BuildContext context) {
  140. final copyState = ref.watch(myCopyTradingProvider);
  141. if (copyState.isLoading && copyState.currentPositions.isEmpty) {
  142. return Column(
  143. children: List.generate(2, (_) => const _CopyPositionSkeleton()),
  144. );
  145. }
  146. return _CurrentPositionsList(
  147. positions: copyState.currentPositions,
  148. onClose: (positionId) async {
  149. final confirmed = await showConfirmDialog(
  150. context,
  151. content: AppLocalizations.of(context)!.closePositionConfirm,
  152. );
  153. if (!confirmed || !context.mounted) return;
  154. try {
  155. await ref.read(myCopyTradingProvider.notifier).closePosition(positionId);
  156. if (context.mounted) {
  157. showTopToast(context, message: AppLocalizations.of(context)!.closePositionSuccess, backgroundColor: AppColors.rise);
  158. }
  159. } catch (e) {
  160. if (context.mounted) {
  161. showTopToast(context, message: extractErrorMessage(e), backgroundColor: AppColors.fall);
  162. }
  163. }
  164. },
  165. );
  166. }
  167. }
  168. /// 跟单持仓卡片骨架(资产页跟单 Tab 使用)
  169. class _CopyPositionSkeleton extends StatelessWidget {
  170. const _CopyPositionSkeleton();
  171. @override
  172. Widget build(BuildContext context) {
  173. final isDark = Theme.of(context).brightness == Brightness.dark;
  174. return AppShimmer(
  175. child: Container(
  176. margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  177. padding: const EdgeInsets.all(16),
  178. decoration: BoxDecoration(
  179. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  180. borderRadius: BorderRadius.circular(16),
  181. boxShadow: [
  182. BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
  183. ],
  184. ),
  185. child: Column(
  186. crossAxisAlignment: CrossAxisAlignment.start,
  187. children: [
  188. Row(children: [
  189. shimmerCircle(38),
  190. const SizedBox(width: 10),
  191. Expanded(child: shimmerBox(100, 15)),
  192. shimmerBox(60, 32, radius: 8),
  193. ]),
  194. const SizedBox(height: 10),
  195. Row(children: [
  196. shimmerBox(80, 14),
  197. const SizedBox(width: 8),
  198. shimmerBox(60, 20, radius: 4),
  199. const SizedBox(width: 6),
  200. shimmerBox(30, 20, radius: 4),
  201. ]),
  202. const SizedBox(height: 10),
  203. Row(children: List.generate(3, (i) => Expanded(
  204. child: Padding(
  205. padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
  206. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  207. shimmerBox(65, 11),
  208. const SizedBox(height: 4),
  209. shimmerBox(50, 13),
  210. ]),
  211. ),
  212. ))),
  213. const SizedBox(height: 8),
  214. Row(children: List.generate(3, (i) => Expanded(
  215. child: Padding(
  216. padding: EdgeInsets.only(right: i < 2 ? 8 : 0),
  217. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  218. shimmerBox(65, 11),
  219. const SizedBox(height: 4),
  220. shimmerBox(50, 13),
  221. ]),
  222. ),
  223. ))),
  224. ],
  225. ),
  226. ),
  227. );
  228. }
  229. }
  230. /// Widget to display current copy trading positions
  231. class _CurrentPositionsList extends StatelessWidget {
  232. const _CurrentPositionsList({
  233. required this.positions,
  234. required this.onClose,
  235. });
  236. final List<dynamic> positions;
  237. final Future<void> Function(String positionId) onClose;
  238. @override
  239. Widget build(BuildContext context) {
  240. if (positions.isEmpty) {
  241. return Padding(
  242. padding: const EdgeInsets.symmetric(vertical: 32),
  243. child: Center(
  244. child: Text(
  245. AppLocalizations.of(context)!.noFollowing,
  246. style: TextStyle(
  247. color: Theme.of(context).colorScheme.onSurface.withAlpha(153),
  248. fontSize: 14,
  249. ),
  250. ),
  251. ),
  252. );
  253. }
  254. return Column(
  255. children: positions
  256. .map((position) => CopyPositionCard(
  257. position: position,
  258. isHistory: false,
  259. onClose: (_) async => onClose(position.id),
  260. ))
  261. .toList(),
  262. );
  263. }
  264. }