asset_futures_tab.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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/number_format.dart';
  7. import '../../../providers/asset_provider.dart';
  8. import '../../../providers/futures_provider.dart' show FuturesPosition, OrderSide;
  9. import '../../../providers/market_provider.dart' show marketProvider;
  10. import '../../widgets/common/app_refresh_indicator.dart';
  11. import '../futures/futures_screen.dart' show SharePositionSheet;
  12. /// marginMode → l10n 标签
  13. String _marginModeLabel(String mode, AppLocalizations l10n) {
  14. switch (mode) {
  15. case '分仓': return l10n.splitMargin;
  16. default: return l10n.crossMargin;
  17. }
  18. }
  19. /// marginMode → 标签颜色
  20. Color _marginModeColor(String mode) {
  21. switch (mode) {
  22. case '分仓':
  23. case '逐仓': return AppColors.rankPurple;
  24. default: return AppColors.tagBlue;
  25. }
  26. }
  27. /// 合约 Tab
  28. class AssetFuturesTab extends ConsumerWidget {
  29. const AssetFuturesTab({super.key, required this.state, required this.notifier});
  30. final AssetState state;
  31. final AssetNotifier notifier;
  32. @override
  33. Widget build(BuildContext context, WidgetRef ref) {
  34. final cs = Theme.of(context).colorScheme;
  35. final l10n = AppLocalizations.of(context)!;
  36. final obscure = state.obscureBalance;
  37. final swapBalance = state.walletBalanceExcludeEG('SWAP').toDouble();
  38. final display = obscure ? '******' : formatPrice(swapBalance, decimalPlaces: 2);
  39. return AppRefreshIndicator(
  40. onRefresh: notifier.silentRefresh,
  41. child: ListView(
  42. children: [
  43. // 合约账户余额
  44. Padding(
  45. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  46. child: Column(
  47. crossAxisAlignment: CrossAxisAlignment.start,
  48. children: [
  49. Row(
  50. children: [
  51. Text(l10n.futuresAccount, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  52. const SizedBox(width: 6),
  53. GestureDetector(
  54. onTap: notifier.toggleObscure,
  55. child: Icon(
  56. obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  57. size: 16, color: cs.onSurface.withAlpha(153),
  58. ),
  59. ),
  60. ],
  61. ),
  62. const SizedBox(height: 8),
  63. Row(
  64. crossAxisAlignment: CrossAxisAlignment.end,
  65. children: [
  66. Text(display, style: TextStyle(color: cs.onSurface, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
  67. const SizedBox(width: 6),
  68. Padding(
  69. padding: const EdgeInsets.only(bottom: 5),
  70. child: Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  71. ),
  72. ],
  73. ),
  74. const SizedBox(height: 12),
  75. // 钱包余额
  76. Row(
  77. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  78. children: [
  79. Text(l10n.walletBalance, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  80. Text(
  81. obscure ? '******' : formatPrice(state.accountBalance('SWAP').toDouble(), decimalPlaces: 2),
  82. style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
  83. ),
  84. ],
  85. ),
  86. const SizedBox(height: 8),
  87. Row(
  88. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  89. children: [
  90. Text(l10n.unrealizedPnl, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  91. Text(
  92. obscure ? '******' : formatPrice(state.unrealizedPnl('SWAP').toDouble(), decimalPlaces: 2),
  93. style: TextStyle(
  94. color: _getPnlColor(context, state.unrealizedPnl('SWAP').toDouble()),
  95. fontSize: 13,
  96. fontWeight: FontWeight.w500,
  97. ),
  98. ),
  99. ],
  100. ),
  101. ],
  102. ),
  103. ),
  104. // 交易 + 划转 按钮
  105. Padding(
  106. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  107. child: Row(
  108. children: [
  109. Expanded(
  110. child: GestureDetector(
  111. onTap: () {
  112. final symbols = ref.read(marketProvider).displaySymbols;
  113. final symbol = symbols.isNotEmpty ? symbols.first : 'BTCUSDT';
  114. context.go('/futures/$symbol');
  115. },
  116. child: Container(
  117. height: 44,
  118. decoration: BoxDecoration(
  119. color: AppColors.brand,
  120. borderRadius: BorderRadius.circular(22),
  121. ),
  122. child: Center(
  123. child: Text(l10n.futures, style: const TextStyle(color: Colors.black, fontSize: 15, fontWeight: FontWeight.w600)),
  124. ),
  125. ),
  126. ),
  127. ),
  128. const SizedBox(width: 12),
  129. Expanded(
  130. child: GestureDetector(
  131. onTap: () => context.push('/asset/transfer?from=SWAP&to=SPOT'),
  132. child: Container(
  133. height: 44,
  134. decoration: BoxDecoration(
  135. color: AppColors.brand,
  136. borderRadius: BorderRadius.circular(22),
  137. ),
  138. child: Center(
  139. child: Text(l10n.transfer, style: const TextStyle(color: Colors.black, fontSize: 15, fontWeight: FontWeight.w600)),
  140. ),
  141. ),
  142. ),
  143. ),
  144. ],
  145. ),
  146. ),
  147. const SizedBox(height: 24),
  148. // 持仓标题
  149. Padding(
  150. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  151. child: Text(l10n.positions, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  152. ),
  153. if (state.positions.isEmpty)
  154. Padding(
  155. padding: const EdgeInsets.symmetric(vertical: 40),
  156. child: Center(
  157. child: Text(l10n.noPositions,
  158. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  159. ),
  160. )
  161. else
  162. for (final pos in state.positions)
  163. _AssetPositionCard(position: pos),
  164. const SizedBox(height: 32),
  165. ],
  166. ),
  167. );
  168. }
  169. Color _getPnlColor(BuildContext context, double pnl) {
  170. if (pnl > 0) return AppColors.rise;
  171. if (pnl < 0) return AppColors.fall;
  172. return Theme.of(context).colorScheme.onSurface;
  173. }
  174. }
  175. /// 资产合约 tab 的持仓卡片,样式与合约页面 _PositionCard 一致
  176. class _AssetPositionCard extends StatelessWidget {
  177. const _AssetPositionCard({required this.position});
  178. final FuturesPosition position;
  179. @override
  180. Widget build(BuildContext context) {
  181. final cs = Theme.of(context).colorScheme;
  182. final l10n = AppLocalizations.of(context)!;
  183. final coinName = position.symbol.replaceAll('/', '').replaceAll('USDT', '');
  184. final isLong = position.side == OrderSide.long;
  185. final pnlColor = position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall;
  186. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  187. final symbol = '${coinName}USDT';
  188. return GestureDetector(
  189. onTap: () => context.go('/futures/$symbol'),
  190. child: Container(
  191. decoration: BoxDecoration(
  192. border: Border(bottom: BorderSide(color: cs.outline.withAlpha(50))),
  193. ),
  194. padding: const EdgeInsets.fromLTRB(12, 10, 12, 16),
  195. child: Column(
  196. crossAxisAlignment: CrossAxisAlignment.start,
  197. children: [
  198. // ── 标题行 ──────────────────────────────────────────
  199. Row(
  200. children: [
  201. Container(
  202. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  203. decoration: BoxDecoration(
  204. color: sideColor,
  205. borderRadius: BorderRadius.circular(3),
  206. ),
  207. child: Text(
  208. isLong ? l10n.openLong : l10n.openShort,
  209. style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
  210. ),
  211. ),
  212. const SizedBox(width: 4),
  213. Expanded(
  214. child: Row(
  215. children: [
  216. Flexible(
  217. child: Text(
  218. '${coinName}USDT',
  219. maxLines: 1,
  220. overflow: TextOverflow.ellipsis,
  221. style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w700),
  222. ),
  223. ),
  224. const SizedBox(width: 4),
  225. _SmallTag(text: l10n.perpetual),
  226. const SizedBox(width: 3),
  227. _SmallTag(
  228. text: _marginModeLabel(position.marginMode, l10n),
  229. color: _marginModeColor(position.marginMode),
  230. ),
  231. const SizedBox(width: 3),
  232. Container(
  233. padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
  234. decoration: BoxDecoration(
  235. color: AppColors.leverageGoldBg,
  236. borderRadius: BorderRadius.circular(3),
  237. ),
  238. child: Text(
  239. '${position.leverage.toInt()}X',
  240. style: const TextStyle(color: AppColors.leverageGold, fontSize: 10, fontWeight: FontWeight.w700),
  241. ),
  242. ),
  243. ],
  244. ),
  245. ),
  246. const SizedBox(width: 8),
  247. GestureDetector(
  248. onTap: () => _sharePosition(context),
  249. child: Icon(Icons.share_outlined, size: 16, color: cs.onSurface.withAlpha(153)),
  250. ),
  251. ],
  252. ),
  253. const SizedBox(height: 8),
  254. // ── 未实现盈亏 + 收益率 ──────────────────────────────
  255. Row(
  256. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  257. crossAxisAlignment: CrossAxisAlignment.end,
  258. children: [
  259. Column(
  260. crossAxisAlignment: CrossAxisAlignment.start,
  261. children: [
  262. Text('${l10n.unrealizedPnl} (USDT)',
  263. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
  264. Text(
  265. formatAmount(position.unrealizedPnl),
  266. style: TextStyle(color: pnlColor, fontSize: 16, fontWeight: FontWeight.w700),
  267. ),
  268. ],
  269. ),
  270. Column(
  271. crossAxisAlignment: CrossAxisAlignment.end,
  272. children: [
  273. Text(l10n.returnRate,
  274. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
  275. Text(
  276. '${formatAmount(position.roe)}%',
  277. style: TextStyle(color: pnlColor, fontSize: 13, fontWeight: FontWeight.w600),
  278. ),
  279. ],
  280. ),
  281. ],
  282. ),
  283. const SizedBox(height: 6),
  284. // ── 数据行 1 ────────────────────────────────────────
  285. Row(
  286. children: [
  287. _DataCol(
  288. label: '${l10n.positionSize}($coinName)',
  289. value: formatQuantity(position.size),
  290. align: CrossAxisAlignment.start,
  291. ),
  292. _DataCol(
  293. label: '${l10n.marginLabel}(USDT)',
  294. value: formatAmount(position.margin),
  295. align: CrossAxisAlignment.center,
  296. ),
  297. _DataCol(
  298. label: l10n.marginRatioLabel,
  299. value: '${formatAmount(position.marginRatio)}%',
  300. align: CrossAxisAlignment.end,
  301. ),
  302. ],
  303. ),
  304. const SizedBox(height: 4),
  305. // ── 数据行 2 ────────────────────────────────────────
  306. Row(
  307. children: [
  308. _DataCol(
  309. label: '${l10n.openAvgPrice}(USDT)',
  310. value: formatPrice(position.entryPrice),
  311. align: CrossAxisAlignment.start,
  312. ),
  313. _DataCol(
  314. label: '${l10n.latestLabel}(USDT)',
  315. value: formatPrice(position.markPrice),
  316. align: CrossAxisAlignment.center,
  317. ),
  318. _DataCol(
  319. label: '${l10n.liqPrice}(USDT)',
  320. value: position.liquidationPrice > 0 ? formatPrice(position.liquidationPrice) : '--',
  321. valueColor: position.liquidationPrice > 0 ? AppColors.fall : cs.onSurface.withAlpha(153),
  322. align: CrossAxisAlignment.end,
  323. ),
  324. ],
  325. ),
  326. ],
  327. ),
  328. ),
  329. );
  330. }
  331. void _sharePosition(BuildContext context) {
  332. showModalBottomSheet<void>(
  333. context: context,
  334. useRootNavigator: true,
  335. backgroundColor: Colors.transparent,
  336. isScrollControlled: true,
  337. builder: (_) => SharePositionSheet(position: position),
  338. );
  339. }
  340. }
  341. class _SmallTag extends StatelessWidget {
  342. const _SmallTag({required this.text, this.color});
  343. final String text;
  344. final Color? color;
  345. @override
  346. Widget build(BuildContext context) {
  347. final cs = Theme.of(context).colorScheme;
  348. final isDark = Theme.of(context).brightness == Brightness.dark;
  349. final c = color;
  350. if (c != null) {
  351. return Container(
  352. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
  353. decoration: BoxDecoration(
  354. color: c.withAlpha(isDark ? 45 : 25),
  355. borderRadius: BorderRadius.circular(3),
  356. ),
  357. child: Text(text, style: TextStyle(color: c, fontSize: 9, fontWeight: FontWeight.w500)),
  358. );
  359. }
  360. return Container(
  361. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
  362. decoration: BoxDecoration(
  363. borderRadius: BorderRadius.circular(3),
  364. border: Border.all(color: cs.outline.withAlpha(100)),
  365. ),
  366. child: Text(text, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
  367. );
  368. }
  369. }
  370. class _DataCol extends StatelessWidget {
  371. const _DataCol({
  372. required this.label,
  373. required this.value,
  374. this.valueColor,
  375. this.align = CrossAxisAlignment.start,
  376. });
  377. final String label;
  378. final String value;
  379. final Color? valueColor;
  380. final CrossAxisAlignment align;
  381. @override
  382. Widget build(BuildContext context) {
  383. final cs = Theme.of(context).colorScheme;
  384. final textAlign = align == CrossAxisAlignment.end
  385. ? TextAlign.right
  386. : align == CrossAxisAlignment.center
  387. ? TextAlign.center
  388. : TextAlign.left;
  389. return Expanded(
  390. child: Column(
  391. crossAxisAlignment: align,
  392. children: [
  393. Text(label,
  394. maxLines: 1,
  395. overflow: TextOverflow.ellipsis,
  396. textAlign: textAlign,
  397. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
  398. Text(value,
  399. maxLines: 1,
  400. overflow: TextOverflow.ellipsis,
  401. textAlign: textAlign,
  402. style: TextStyle(
  403. color: valueColor ?? cs.onSurface,
  404. fontSize: 12,
  405. fontWeight: FontWeight.w500)),
  406. ],
  407. ),
  408. );
  409. }
  410. }