order_detail_screen.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/number_format.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../providers/futures_provider.dart';
  10. /// 委托详情页
  11. class OrderDetailScreen extends ConsumerStatefulWidget {
  12. const OrderDetailScreen({super.key, required this.order});
  13. final FuturesOrder order;
  14. @override
  15. ConsumerState<OrderDetailScreen> createState() => _OrderDetailScreenState();
  16. }
  17. class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
  18. bool _cancelling = false;
  19. FuturesOrder get order => widget.order;
  20. String _baseCoin(String sym) {
  21. if (sym.contains('/')) return sym.split('/').first;
  22. return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
  23. }
  24. String _formatDateTime(DateTime? dt) {
  25. if (dt == null) return '--';
  26. final y = dt.year;
  27. final mo = dt.month.toString().padLeft(2, '0');
  28. final d = dt.day.toString().padLeft(2, '0');
  29. final h = dt.hour.toString().padLeft(2, '0');
  30. final mi = dt.minute.toString().padLeft(2, '0');
  31. final s = dt.second.toString().padLeft(2, '0');
  32. return '$y-$mo-$d $h:$mi:$s';
  33. }
  34. Future<void> _cancelOrder(BuildContext context) async {
  35. setState(() => _cancelling = true);
  36. final activeSymbol = ref.read(futuresActiveSymbolProvider);
  37. final notifier = ref.read(futuresProvider(activeSymbol).notifier);
  38. final err = await notifier.cancelOrder(order);
  39. if (!context.mounted) return;
  40. setState(() => _cancelling = false);
  41. final l10n = AppLocalizations.of(context)!;
  42. if (err == null) {
  43. showTopToast(context, message: l10n.cancelOrderSuccess, backgroundColor: AppColors.rise);
  44. context.pop();
  45. } else {
  46. showTopToast(context, message: resolveProviderError(err, l10n) ?? err, backgroundColor: AppColors.fall);
  47. }
  48. }
  49. /// 去除多余的尾零,整数不带小数点;为空/未传才显示 '--',由调用方处理
  50. String _rawNum(double v) {
  51. if (v == v.truncateToDouble()) return v.toInt().toString();
  52. return v.toString();
  53. }
  54. @override
  55. Widget build(BuildContext context) {
  56. final cs = Theme.of(context).colorScheme;
  57. final l10n = AppLocalizations.of(context)!;
  58. final isOpen = order.isOpenOrder;
  59. final isLong = order.side == OrderSide.long;
  60. final actionColor = isOpen
  61. ? (isLong ? AppColors.rise : AppColors.fall)
  62. : (isLong ? AppColors.fall : AppColors.rise);
  63. final coinSymbol = _baseCoin(order.symbol);
  64. final hasTrigger = order.triggerPrice > 0;
  65. final hasProfit = order.profitPrice != null && order.profitPrice! > 0;
  66. final hasLoss = order.lossPrice != null && order.lossPrice! > 0;
  67. final hasTpsl = hasProfit || hasLoss;
  68. final canCancel = order.isPending;
  69. final isDark = Theme.of(context).brightness == Brightness.dark;
  70. final scaffoldBg = isDark ? AppColors.darkBg : AppColors.lightBgSecondary;
  71. final cardBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  72. return Scaffold(
  73. backgroundColor: scaffoldBg,
  74. appBar: AppBar(
  75. elevation: 0,
  76. centerTitle: true,
  77. backgroundColor: cardBg,
  78. title: Text(
  79. l10n.orderDetail,
  80. style: TextStyle(
  81. color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
  82. ),
  83. ),
  84. body: Stack(
  85. children: [
  86. SingleChildScrollView(
  87. padding: EdgeInsets.only(bottom: canCancel ? 90 : 16),
  88. child: Column(
  89. crossAxisAlignment: CrossAxisAlignment.start,
  90. children: [
  91. // ── 标的行 ──────────────────────────────────────
  92. Container(
  93. color: cardBg,
  94. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  95. child: Row(
  96. children: [
  97. // 开多/开空/平多/平空 chip
  98. Container(
  99. padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
  100. decoration: BoxDecoration(
  101. color: actionColor,
  102. borderRadius: BorderRadius.circular(4),
  103. ),
  104. child: Text(
  105. isOpen
  106. ? (isLong ? l10n.openLong : l10n.openShort)
  107. : (isLong ? l10n.closeLong : l10n.closeShort),
  108. style: const TextStyle(
  109. color: Colors.white,
  110. fontSize: 11,
  111. fontWeight: FontWeight.w700),
  112. ),
  113. ),
  114. const SizedBox(width: 6),
  115. Expanded(
  116. child: Row(
  117. children: [
  118. Flexible(
  119. child: Text(
  120. order.symbol,
  121. maxLines: 1,
  122. overflow: TextOverflow.ellipsis,
  123. style: TextStyle(
  124. color: cs.onSurface,
  125. fontSize: 15,
  126. fontWeight: FontWeight.w700),
  127. ),
  128. ),
  129. const SizedBox(width: 6),
  130. _SmTag(text: order.type == OrderType.market
  131. ? l10n.marketHint
  132. : order.type == OrderType.limit
  133. ? l10n.limitLabel
  134. : l10n.planOrderLabel),
  135. const SizedBox(width: 4),
  136. _SmTag(
  137. text: switch (order.marginMode) {
  138. '分仓' || '逐仓' => l10n.splitMargin,
  139. _ => l10n.crossMargin,
  140. },
  141. color: switch (order.marginMode) {
  142. '分仓' || '逐仓' => AppColors.rankPurple,
  143. _ => AppColors.tagBlue,
  144. },
  145. ),
  146. const SizedBox(width: 4),
  147. _LevTag(leverage: order.leverage.toInt()),
  148. ],
  149. ),
  150. ),
  151. ],
  152. ),
  153. ),
  154. const SizedBox(height: 8),
  155. // ── 委托基本信息 ─────────────────────────────────
  156. _InfoCard(rows: [
  157. _InfoRowData('${l10n.orderPriceLabel}(USDT)', order.priceDisplay),
  158. _InfoRowData('${l10n.entrustAmount}($coinSymbol)', _rawNum(order.size)),
  159. _InfoRowData('${l10n.filledVolume}($coinSymbol)', _rawNum(order.filledSize)),
  160. _InfoRowData(
  161. l10n.avgTradePrice,
  162. order.tradedPrice > 0 ? _rawNum(order.tradedPrice) : '--',
  163. ),
  164. if (hasTrigger)
  165. _InfoRowData(
  166. l10n.triggerCondition,
  167. '${l10n.markLabel}≥${_rawNum(order.triggerPrice)}',
  168. valueColor: cs.onSurface.withAlpha(100),
  169. isLast: true,
  170. ),
  171. ]),
  172. const SizedBox(height: 8),
  173. // ── 止盈止损 ─────────────────────────────────────
  174. if (hasTpsl) ...[
  175. _InfoCard(
  176. sectionTitle: l10n.stopProfitLoss,
  177. rows: [
  178. if (hasProfit)
  179. _InfoRowData(
  180. '${l10n.takeProfitPrice}(USDT)',
  181. _rawNum(order.profitPrice!),
  182. valueColor: AppColors.rise,
  183. isLast: !hasLoss,
  184. ),
  185. if (hasLoss)
  186. _InfoRowData(
  187. '${l10n.stopLossPrice}(USDT)',
  188. _rawNum(order.lossPrice!),
  189. valueColor: AppColors.fall,
  190. isLast: true,
  191. ),
  192. ],
  193. ),
  194. const SizedBox(height: 8),
  195. ],
  196. // ── 时间 + 订单 ID ───────────────────────────────
  197. _InfoCard(rows: [
  198. _InfoRowData(l10n.createTime, _formatDateTime(order.createTime)),
  199. _InfoRowData(
  200. 'ID',
  201. order.id,
  202. isLast: true,
  203. copyable: true,
  204. ),
  205. ]),
  206. const SizedBox(height: 8),
  207. // ── 交易明细(已成交时显示)──────────────────────────
  208. if (order.filledSize > 0) ...[
  209. _InfoCard(
  210. sectionTitle: l10n.tradeDetailLabel,
  211. rows: [
  212. _InfoRowData(l10n.tradePrice, order.tradedPrice > 0 ? _rawNum(order.tradedPrice) : '--'),
  213. _InfoRowData('${l10n.filledVolume}($coinSymbol)', _rawNum(order.filledSize)),
  214. if (order.fee > 0)
  215. _InfoRowData('${l10n.fee}(USDT)', formatAmount(order.fee)),
  216. _InfoRowData(l10n.tradeTime, _formatDateTime(order.dealTime ?? order.createTime), isLast: true),
  217. ],
  218. ),
  219. const SizedBox(height: 8),
  220. ],
  221. ],
  222. ),
  223. ),
  224. // ── 撤单按钮(仅委托中状态)─────────────────────────────
  225. if (canCancel)
  226. Positioned(
  227. left: 0,
  228. right: 0,
  229. bottom: 0,
  230. child: Container(
  231. color: Theme.of(context).brightness == Brightness.dark
  232. ? AppColors.darkBgSecondary
  233. : AppColors.lightBg,
  234. padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
  235. child: SizedBox(
  236. height: 50,
  237. child: ElevatedButton(
  238. onPressed: _cancelling ? null : () => _cancelOrder(context),
  239. style: ElevatedButton.styleFrom(
  240. backgroundColor: AppColors.brand,
  241. elevation: 0,
  242. shape: RoundedRectangleBorder(
  243. borderRadius: BorderRadius.circular(25)),
  244. ),
  245. child: _cancelling
  246. ? const SizedBox(
  247. width: 20,
  248. height: 20,
  249. child: CircularProgressIndicator(
  250. strokeWidth: 2, color: Colors.white),
  251. )
  252. : Text(
  253. l10n.cancelOrder,
  254. style: const TextStyle(
  255. color: Colors.white,
  256. fontSize: 16,
  257. fontWeight: FontWeight.w700,
  258. letterSpacing: 2),
  259. ),
  260. ),
  261. ),
  262. ),
  263. ),
  264. ],
  265. ),
  266. );
  267. }
  268. }
  269. // ── 共用 widgets ───────────────────────────────────────────────
  270. class _SmTag extends StatelessWidget {
  271. const _SmTag({required this.text, this.color});
  272. final String text;
  273. final Color? color;
  274. @override
  275. Widget build(BuildContext context) {
  276. final cs = Theme.of(context).colorScheme;
  277. final isDark = Theme.of(context).brightness == Brightness.dark;
  278. final c = color;
  279. if (c != null) {
  280. return Container(
  281. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  282. decoration: BoxDecoration(
  283. color: c.withAlpha(isDark ? 45 : 25),
  284. borderRadius: BorderRadius.circular(4),
  285. ),
  286. child: Text(text,
  287. style: TextStyle(
  288. color: c, fontSize: 11, fontWeight: FontWeight.w600)),
  289. );
  290. }
  291. return Container(
  292. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  293. decoration: BoxDecoration(
  294. color: cs.onSurface.withAlpha(12),
  295. border: Border.all(color: cs.outline.withAlpha(80)),
  296. borderRadius: BorderRadius.circular(4),
  297. ),
  298. child: Text(text,
  299. style: TextStyle(
  300. color: cs.onSurface.withAlpha(153),
  301. fontSize: 11,
  302. fontWeight: FontWeight.w600)),
  303. );
  304. }
  305. }
  306. class _LevTag extends StatelessWidget {
  307. const _LevTag({required this.leverage});
  308. final int leverage;
  309. @override
  310. Widget build(BuildContext context) {
  311. return Container(
  312. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  313. decoration: BoxDecoration(
  314. color: AppColors.leverageGoldBg,
  315. borderRadius: BorderRadius.circular(4),
  316. ),
  317. child: Text('${leverage}X',
  318. style: const TextStyle(
  319. color: AppColors.leverageGold,
  320. fontSize: 11,
  321. fontWeight: FontWeight.w700)),
  322. );
  323. }
  324. }
  325. class _InfoRowData {
  326. const _InfoRowData(this.label, this.value,
  327. {this.valueColor, this.isLast = false, this.copyable = false});
  328. final String label;
  329. final String value;
  330. final Color? valueColor;
  331. final bool isLast;
  332. final bool copyable;
  333. }
  334. class _InfoCard extends StatelessWidget {
  335. const _InfoCard({required this.rows, this.sectionTitle});
  336. final List<_InfoRowData> rows;
  337. final String? sectionTitle;
  338. @override
  339. Widget build(BuildContext context) {
  340. final cs = Theme.of(context).colorScheme;
  341. final isDark = Theme.of(context).brightness == Brightness.dark;
  342. return Container(
  343. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  344. child: Column(
  345. crossAxisAlignment: CrossAxisAlignment.start,
  346. children: [
  347. if (sectionTitle != null)
  348. Padding(
  349. padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
  350. child: Text(sectionTitle!,
  351. style: TextStyle(
  352. color: cs.onSurface,
  353. fontSize: 14,
  354. fontWeight: FontWeight.w700)),
  355. ),
  356. ...rows.map((r) {
  357. return Column(
  358. children: [
  359. Padding(
  360. padding: const EdgeInsets.symmetric(
  361. horizontal: 16, vertical: 13),
  362. child: Row(
  363. children: [
  364. Expanded(
  365. child: Text(r.label,
  366. overflow: TextOverflow.ellipsis,
  367. style: TextStyle(
  368. color: cs.onSurface.withAlpha(153),
  369. fontSize: 13)),
  370. ),
  371. const SizedBox(width: 8),
  372. Row(
  373. mainAxisSize: MainAxisSize.min,
  374. children: [
  375. Text(r.value,
  376. overflow: TextOverflow.ellipsis,
  377. textAlign: TextAlign.end,
  378. style: TextStyle(
  379. color: r.valueColor ?? cs.onSurface,
  380. fontSize: 13,
  381. fontWeight: FontWeight.w500)),
  382. if (r.copyable) ...[
  383. const SizedBox(width: 6),
  384. GestureDetector(
  385. onTap: () {
  386. Clipboard.setData(
  387. ClipboardData(text: r.value));
  388. showTopToast(context, message: AppLocalizations.of(context)!.copied, backgroundColor: AppColors.rise);
  389. },
  390. child: Icon(Icons.copy_outlined,
  391. size: 15,
  392. color: cs.onSurface.withAlpha(100)),
  393. ),
  394. ],
  395. ],
  396. ),
  397. ],
  398. ),
  399. ),
  400. if (!r.isLast)
  401. Divider(
  402. height: 1,
  403. thickness: 0.5,
  404. indent: 16,
  405. endIndent: 16,
  406. color: cs.outline.withAlpha(60)),
  407. ],
  408. );
  409. }).toList(),
  410. ],
  411. ),
  412. );
  413. }
  414. }