spot_provider.dart 29 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  1. import 'dart:async';
  2. import 'dart:math' as math;
  3. import 'package:dio/dio.dart' show DioException, DioExceptionType;
  4. import 'package:flutter/foundation.dart';
  5. import 'package:flutter_riverpod/flutter_riverpod.dart';
  6. import '../core/network/api_response.dart';
  7. import '../core/network/dio_client.dart';
  8. import '../core/network/spot_ws_client.dart';
  9. import '../data/services/spot_service.dart';
  10. import 'auth_provider.dart';
  11. import 'profile_provider.dart';
  12. import 'spot_ws_provider.dart';
  13. // 枚举
  14. /// 买卖方向
  15. enum SpotSide { buy, sell }
  16. /// 限价 / 市价 / 条件市价(条件为客户端占位)
  17. enum SpotOrderType { limit, market, conditionalMarket }
  18. /// 交易页底栏:委托 / 资产
  19. enum SpotTab { orders, assets }
  20. /// 数量单位:base 或 quote(市价买固定 quote)
  21. enum SpotAmountUnit { base, quote }
  22. // 数据模型
  23. class SpotSymbolInfo {
  24. final String symbol; // 如 BTC/USDT
  25. final String base; // BTC
  26. final String quote; // USDT
  27. final int pricePrecision;
  28. final int volumePrecision;
  29. final int depth0Pre;
  30. final int depth1Pre;
  31. final int depth2Pre;
  32. final double minLimitPrice;
  33. final double minLimitVolume;
  34. final double minMarketBuy;
  35. final double minMarketSell;
  36. final bool enableMarketBuy;
  37. final bool enableMarketSell;
  38. final double makerFee;
  39. final double takerFee;
  40. const SpotSymbolInfo({
  41. required this.symbol,
  42. required this.base,
  43. required this.quote,
  44. this.pricePrecision = 2,
  45. this.volumePrecision = 4,
  46. this.depth0Pre = 2,
  47. this.depth1Pre = 4,
  48. this.depth2Pre = 6,
  49. this.minLimitPrice = 0,
  50. this.minLimitVolume = 0,
  51. this.minMarketBuy = 0,
  52. this.minMarketSell = 0,
  53. this.enableMarketBuy = true,
  54. this.enableMarketSell = true,
  55. this.makerFee = 0.001,
  56. this.takerFee = 0.001,
  57. });
  58. factory SpotSymbolInfo.fromJson(Map<String, dynamic> json) {
  59. final sym = (json['symbol'] as String? ?? '').toUpperCase();
  60. final base = (json['base'] as String? ?? '').toUpperCase();
  61. final quote = (json['quote'] as String? ?? 'USDT').toUpperCase();
  62. return SpotSymbolInfo(
  63. symbol: sym,
  64. base: base.isNotEmpty ? base : _deriveBaseFallback(sym),
  65. quote: quote.isNotEmpty ? quote : 'USDT',
  66. pricePrecision: _toInt(json['pricePre']) ?? 2,
  67. volumePrecision: _toInt(json['volumePre']) ?? 4,
  68. depth0Pre: _toInt(json['depth0Pre']) ?? 2,
  69. depth1Pre: _toInt(json['depth1Pre']) ?? 4,
  70. depth2Pre: _toInt(json['depth2Pre']) ?? 6,
  71. minLimitPrice: _toDouble(json['limitPriceMin']),
  72. minLimitVolume: _toDouble(json['limitVolumeMin']),
  73. minMarketBuy: _toDouble(json['marketBuyMin']),
  74. minMarketSell: _toDouble(json['marketSellMin']),
  75. enableMarketBuy: (json['marketBuyMin'] as num? ?? 0) > 0,
  76. enableMarketSell: (json['marketSellMin'] as num? ?? 0) > 0,
  77. makerFee: _toDouble(json['makerFee']),
  78. takerFee: _toDouble(json['takerFee']),
  79. );
  80. }
  81. static String _deriveBaseFallback(String sym) {
  82. for (final q in const ['USDT', 'BTC', 'ETH', 'BUSD']) {
  83. if (sym.endsWith(q) && sym.length > q.length) {
  84. return sym.substring(0, sym.length - q.length);
  85. }
  86. }
  87. return sym;
  88. }
  89. }
  90. class SpotWalletAsset {
  91. final String coin;
  92. final double balance;
  93. final double frozenBalance;
  94. const SpotWalletAsset({
  95. required this.coin,
  96. required this.balance,
  97. required this.frozenBalance,
  98. });
  99. double get total => balance + frozenBalance;
  100. factory SpotWalletAsset.fromJson(Map<String, dynamic> json) {
  101. final coin = (json['symbol'] ?? json['coinId'] ?? json['coin'])
  102. ?.toString()
  103. .toUpperCase() ??
  104. '';
  105. return SpotWalletAsset(
  106. coin: coin,
  107. balance: _toDouble(json['available'] ?? json['balance']),
  108. frozenBalance: _toDouble(json['frozen'] ?? json['frozenBalance']),
  109. );
  110. }
  111. }
  112. class SpotOrder {
  113. final String id;
  114. final String symbol; // BTCUSDT
  115. final SpotSide side;
  116. final SpotOrderType type;
  117. /// 后端 type:1 限价 2 市价 3 条件
  118. final int typeCode;
  119. /// 后端 status,未知 -1
  120. final int statusCode;
  121. final double price; // 限价委托价
  122. final double amount; // 委托量(市价买为 USDT 金额)
  123. final double tradedAmount;
  124. final double tradedTurnover;
  125. final double avgPrice;
  126. final String status; // 文本:委托中/部分成交/已成交/已撤销
  127. final DateTime? createTime;
  128. const SpotOrder({
  129. required this.id,
  130. required this.symbol,
  131. required this.side,
  132. required this.type,
  133. this.typeCode = 1,
  134. this.statusCode = -1,
  135. required this.price,
  136. required this.amount,
  137. this.tradedAmount = 0,
  138. this.tradedTurnover = 0,
  139. this.avgPrice = 0,
  140. this.status = '',
  141. this.createTime,
  142. });
  143. bool get isPending => status == '委托中' || status == '部分成交';
  144. factory SpotOrder.fromJson(Map<String, dynamic> json) {
  145. final dirRaw =
  146. (json['side'] ?? json['direction'] ?? 'BUY').toString().toUpperCase();
  147. final side = dirRaw == 'BUY' ? SpotSide.buy : SpotSide.sell;
  148. final typeRaw = json['type'];
  149. int typeCode = 1;
  150. if (typeRaw is int) {
  151. typeCode = typeRaw;
  152. } else if (typeRaw is String) {
  153. final tr = typeRaw.toUpperCase();
  154. if (tr == 'MARKET_PRICE' || tr == 'MARKET') {
  155. typeCode = 2;
  156. } else if (tr == 'STOP' || tr == 'STOP_LOSS') {
  157. typeCode = 3;
  158. } else {
  159. typeCode = int.tryParse(typeRaw) ?? 1;
  160. }
  161. }
  162. final SpotOrderType type;
  163. if (typeCode == 2 || typeRaw == 'MARKET_PRICE' || typeRaw == 'MARKET') {
  164. type = SpotOrderType.market;
  165. } else if (typeCode == 3) {
  166. type = SpotOrderType.conditionalMarket;
  167. } else {
  168. type = SpotOrderType.limit;
  169. }
  170. final ts = json['ctime'] ?? json['time'] ?? json['createTime'];
  171. DateTime? createTime;
  172. if (ts is int && ts > 0) {
  173. createTime = DateTime.fromMillisecondsSinceEpoch(
  174. ts > 9999999999 ? ts : ts * 1000,
  175. );
  176. } else if (ts is String) {
  177. createTime = DateTime.tryParse(ts);
  178. }
  179. final statusRaw = json['status'];
  180. final statusStr =
  181. statusRaw is String ? statusRaw : statusRaw?.toString() ?? '';
  182. final statusCode = _parseStatusCode(statusRaw, statusStr);
  183. final status = _mapStatus(statusStr);
  184. return SpotOrder(
  185. id: (json['id'] ?? json['orderId'] ?? '').toString(),
  186. symbol: (json['symbol'] as String? ?? '').toUpperCase(),
  187. side: side,
  188. type: type,
  189. typeCode: typeCode,
  190. statusCode: statusCode,
  191. price: _toDouble(json['price']),
  192. amount: _toDouble(json['volume'] ?? json['amount']),
  193. tradedAmount: _toDouble(json['dealVolume'] ?? json['tradedAmount']),
  194. tradedTurnover: _toDouble(json['dealMoney'] ?? json['turnover']),
  195. avgPrice: _toDouble(json['avgPrice'] ?? json['tradedAvgPrice']),
  196. status: status,
  197. createTime: createTime,
  198. );
  199. }
  200. static int _parseStatusCode(dynamic statusRaw, String statusStr) {
  201. if (statusRaw is int) return statusRaw;
  202. final n = int.tryParse(statusStr);
  203. if (n != null) return n;
  204. switch (statusStr.toUpperCase()) {
  205. case 'INIT':
  206. case 'NEW':
  207. case 'TRADING':
  208. return 1;
  209. case 'FILLED':
  210. case 'COMPLETED':
  211. return 2;
  212. case 'PART_FILLED':
  213. case 'PART_TRADED':
  214. return 3;
  215. case 'CANCELED':
  216. case 'CANCELLED':
  217. return 4;
  218. case 'PENDING_CANCEL':
  219. return 5;
  220. case 'EXPIRED':
  221. case 'OVERTIMED':
  222. return 6;
  223. default:
  224. return -1;
  225. }
  226. }
  227. static String _mapStatus(String raw) {
  228. switch (raw.toUpperCase()) {
  229. case '0':
  230. case '1':
  231. return '委托中';
  232. case '3':
  233. return '部分成交';
  234. case '2':
  235. return '已成交';
  236. case '4':
  237. case '5':
  238. case '6':
  239. return '已撤销';
  240. case 'TRADING':
  241. case 'NEW':
  242. case 'INIT':
  243. return '委托中';
  244. case 'PART_FILLED':
  245. case 'PART_TRADED':
  246. return '部分成交';
  247. case 'COMPLETED':
  248. case 'FILLED':
  249. return '已成交';
  250. case 'CANCELED':
  251. case 'CANCELLED':
  252. case 'OVERTIMED':
  253. return '已撤销';
  254. default:
  255. return raw;
  256. }
  257. }
  258. }
  259. /// trade_ticker 成交一条
  260. class SpotPublicTrade {
  261. const SpotPublicTrade({
  262. required this.price,
  263. required this.quantity,
  264. required this.isBuyerMaker,
  265. required this.time,
  266. this.tradeId = '',
  267. });
  268. final double price;
  269. final double quantity;
  270. final bool isBuyerMaker;
  271. final int time;
  272. final String tradeId;
  273. }
  274. class SpotState {
  275. /// 交易对,如 BTCUSDT
  276. final String symbol;
  277. final SpotSymbolInfo? info;
  278. // 行情
  279. final double lastPrice;
  280. final String? lastPriceStr; // WS 返回的原始价格字符串
  281. final double change24h;
  282. final List<Map<String, dynamic>> orderBookAsks;
  283. final List<Map<String, dynamic>> orderBookBids;
  284. final List<SpotPublicTrade> recentPublicTrades;
  285. // 钱包
  286. final List<SpotWalletAsset> wallets;
  287. final double totalAmount; // USDT 总估值
  288. final double todayPnl;
  289. final double todayPnlRate;
  290. // 委托
  291. final List<SpotOrder> openOrders;
  292. final bool ordersHasMore;
  293. final int ordersPage;
  294. // 表单
  295. final SpotSide side;
  296. final SpotOrderType orderType;
  297. final SpotAmountUnit amountUnit; // 限价单可切换;市价买默认 quote(USDT),市价卖默认 base
  298. final double sliderPercent; // 0..1
  299. // UI 状态
  300. final SpotTab activeTab;
  301. final bool isLoading; // 首屏骨架
  302. final bool isTabLoading; // 列表加载
  303. final bool hideOtherSymbols;
  304. const SpotState({
  305. required this.symbol,
  306. this.info,
  307. this.lastPrice = 0,
  308. this.lastPriceStr,
  309. this.change24h = 0,
  310. this.orderBookAsks = const [],
  311. this.orderBookBids = const [],
  312. this.recentPublicTrades = const [],
  313. this.wallets = const [],
  314. this.totalAmount = 0,
  315. this.todayPnl = 0,
  316. this.todayPnlRate = 0,
  317. this.openOrders = const [],
  318. this.ordersHasMore = false,
  319. this.ordersPage = 1,
  320. this.side = SpotSide.buy,
  321. this.orderType = SpotOrderType.market,
  322. this.amountUnit = SpotAmountUnit.quote,
  323. this.sliderPercent = 0,
  324. this.activeTab = SpotTab.orders,
  325. this.isLoading = true,
  326. this.isTabLoading = false,
  327. this.hideOtherSymbols = false,
  328. });
  329. String get apiSymbol => symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase();
  330. String get baseCoin => info?.base ?? _deriveBase(symbol);
  331. String get quoteCoin => info?.quote ?? 'USDT';
  332. int get pricePrecision => info?.pricePrecision ?? 2;
  333. int get volumePrecision => info?.volumePrecision ?? 4;
  334. int get depth0Pre => info?.depth0Pre ?? 2;
  335. int get depth1Pre => info?.depth1Pre ?? 4;
  336. int get depth2Pre => info?.depth2Pre ?? 6;
  337. SpotAmountUnit get effectiveAmountUnit {
  338. if (orderType == SpotOrderType.limit) return amountUnit;
  339. return side == SpotSide.buy ? SpotAmountUnit.quote : SpotAmountUnit.base;
  340. }
  341. bool get showPriceInput => orderType == SpotOrderType.limit;
  342. bool get showTriggerPrice => orderType == SpotOrderType.conditionalMarket;
  343. /// 可用 USDT
  344. double get availableQuote {
  345. final w = wallets.firstWhere(
  346. (a) => a.coin == quoteCoin,
  347. orElse: () => SpotWalletAsset(coin: quoteCoin, balance: 0, frozenBalance: 0),
  348. );
  349. return w.balance;
  350. }
  351. /// 可用 base
  352. double get availableBase {
  353. final w = wallets.firstWhere(
  354. (a) => a.coin == baseCoin,
  355. orElse: () => SpotWalletAsset(coin: baseCoin, balance: 0, frozenBalance: 0),
  356. );
  357. return w.balance;
  358. }
  359. /// openOrders 为全量;开启「隐藏其他」时按本交易对过滤。
  360. List<SpotOrder> get displayOrders {
  361. if (!hideOtherSymbols) return openOrders;
  362. return openOrders.where((o) => o.symbol == apiSymbol).toList();
  363. }
  364. static String _deriveBase(String s) {
  365. final up = s.toUpperCase().replaceAll('/', '').replaceAll('-', '');
  366. for (final q in const ['USDT', 'BTC', 'ETH', 'BUSD']) {
  367. if (up.endsWith(q) && up.length > q.length) {
  368. return up.substring(0, up.length - q.length);
  369. }
  370. }
  371. return up;
  372. }
  373. SpotState copyWith({
  374. String? symbol,
  375. SpotSymbolInfo? info,
  376. double? lastPrice,
  377. String? lastPriceStr,
  378. double? change24h,
  379. List<Map<String, dynamic>>? orderBookAsks,
  380. List<Map<String, dynamic>>? orderBookBids,
  381. List<SpotPublicTrade>? recentPublicTrades,
  382. List<SpotWalletAsset>? wallets,
  383. double? totalAmount,
  384. double? todayPnl,
  385. double? todayPnlRate,
  386. List<SpotOrder>? openOrders,
  387. bool? ordersHasMore,
  388. int? ordersPage,
  389. SpotSide? side,
  390. SpotOrderType? orderType,
  391. SpotAmountUnit? amountUnit,
  392. double? sliderPercent,
  393. SpotTab? activeTab,
  394. bool? isLoading,
  395. bool? isTabLoading,
  396. bool? hideOtherSymbols,
  397. }) {
  398. return SpotState(
  399. symbol: symbol ?? this.symbol,
  400. info: info ?? this.info,
  401. lastPrice: lastPrice ?? this.lastPrice,
  402. lastPriceStr: lastPriceStr ?? this.lastPriceStr,
  403. change24h: change24h ?? this.change24h,
  404. orderBookAsks: orderBookAsks ?? this.orderBookAsks,
  405. orderBookBids: orderBookBids ?? this.orderBookBids,
  406. recentPublicTrades: recentPublicTrades ?? this.recentPublicTrades,
  407. wallets: wallets ?? this.wallets,
  408. totalAmount: totalAmount ?? this.totalAmount,
  409. todayPnl: todayPnl ?? this.todayPnl,
  410. todayPnlRate: todayPnlRate ?? this.todayPnlRate,
  411. openOrders: openOrders ?? this.openOrders,
  412. ordersHasMore: ordersHasMore ?? this.ordersHasMore,
  413. ordersPage: ordersPage ?? this.ordersPage,
  414. side: side ?? this.side,
  415. orderType: orderType ?? this.orderType,
  416. amountUnit: amountUnit ?? this.amountUnit,
  417. sliderPercent: sliderPercent ?? this.sliderPercent,
  418. activeTab: activeTab ?? this.activeTab,
  419. isLoading: isLoading ?? this.isLoading,
  420. isTabLoading: isTabLoading ?? this.isTabLoading,
  421. hideOtherSymbols: hideOtherSymbols ?? this.hideOtherSymbols,
  422. );
  423. }
  424. }
  425. // Notifier
  426. class SpotNotifier extends AutoDisposeFamilyNotifier<SpotState, String> {
  427. StreamSubscription<Map<String, dynamic>>? _tickerSub;
  428. StreamSubscription<Map<String, dynamic>>? _depthSub;
  429. StreamSubscription<Map<String, dynamic>>? _tradeSub;
  430. StreamSubscription<Map<String, dynamic>>? _assetSub;
  431. StreamSubscription<Map<String, dynamic>>? _orderSub;
  432. bool _userPushChannelsRetained = false;
  433. SpotService get _service => SpotService(ref.read(dioClientProvider));
  434. @override
  435. SpotState build(String symbol) {
  436. ref.onDispose(_dispose);
  437. ref.listen<SpotWsClient>(spotWsClientProvider, (prev, next) {
  438. if (prev != null && !identical(prev, next)) {
  439. Future.microtask(() => _bindAllStreams(symbol));
  440. }
  441. });
  442. ref.listen<AsyncValue<SpotWsState>>(spotWsConnectionStateProvider,
  443. (prev, next) {
  444. final s = next.valueOrNull;
  445. if (s != SpotWsState.connected) return;
  446. if (!ref.read(isLoggedInProvider)) return;
  447. if (prev?.valueOrNull == SpotWsState.reconnecting) {
  448. Future.microtask(() async {
  449. try {
  450. await Future.wait([_loadWallets(), _loadCurrentOrders()]);
  451. } catch (_) {}
  452. });
  453. }
  454. });
  455. Future.microtask(() => _init(symbol));
  456. ref.listen<bool>(isLoggedInProvider, (prev, loggedIn) {
  457. if (loggedIn) {
  458. Future.microtask(() async {
  459. try {
  460. await Future.wait([_loadWallets(), _loadCurrentOrders()]);
  461. } catch (_) {}
  462. _subscribeUserPushChannels();
  463. });
  464. } else {
  465. _releaseSpotUserPushChannels();
  466. state = state.copyWith(
  467. wallets: const [],
  468. openOrders: const [],
  469. );
  470. }
  471. });
  472. return SpotState(symbol: symbol, isLoading: true);
  473. }
  474. Future<void> _init(String symbol) async {
  475. await _loadSymbolInfo(symbol);
  476. _subscribeWebSocket(symbol);
  477. state = state.copyWith(isLoading: false, isTabLoading: true);
  478. if (ref.read(isLoggedInProvider)) {
  479. try {
  480. await Future.wait([_loadWallets(), _loadCurrentOrders()]);
  481. } catch (_) {}
  482. _subscribeUserPushChannels();
  483. }
  484. state = state.copyWith(isTabLoading: false);
  485. }
  486. Future<void> _loadSymbolInfo(String symbol) async {
  487. try {
  488. final list = await _service.getSymbols();
  489. final sym = symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase();
  490. final match = list.where((m) {
  491. final s = (m['symbol'] as String? ?? '').toUpperCase();
  492. return s == sym;
  493. }).firstOrNull;
  494. if (match != null) {
  495. state = state.copyWith(info: SpotSymbolInfo.fromJson(match));
  496. }
  497. } catch (_) {}
  498. }
  499. Future<void> _loadWallets() async {
  500. if (!ref.read(isLoggedInProvider)) return;
  501. try {
  502. final data = await _service.getAssets();
  503. final list = data['assetList'];
  504. if (list is List) {
  505. final wallets = list
  506. .whereType<Map<String, dynamic>>()
  507. .map(SpotWalletAsset.fromJson)
  508. .toList();
  509. state = state.copyWith(
  510. wallets: wallets,
  511. totalAmount: _toDouble(data['totalAmount']),
  512. todayPnl: _toDouble(data['todayPnl']),
  513. todayPnlRate: _toDouble(data['todayPnlRate']),
  514. );
  515. }
  516. } catch (_) {}
  517. }
  518. Future<void> _loadCurrentOrders() async {
  519. if (!ref.read(isLoggedInProvider)) return;
  520. try {
  521. final data = await _service.getCurrentOrders();
  522. final records = data['records'];
  523. if (records is List) {
  524. final orders = records
  525. .whereType<Map<String, dynamic>>()
  526. .map(SpotOrder.fromJson)
  527. .toList();
  528. state = state.copyWith(openOrders: orders);
  529. }
  530. } catch (_) {}
  531. }
  532. Future<void> _loadOrderBook(String symbol) async {}
  533. void _bindAllStreams(String symbol) {
  534. _tickerSub?.cancel();
  535. _depthSub?.cancel();
  536. _tradeSub?.cancel();
  537. _tickerSub = null;
  538. _depthSub = null;
  539. _tradeSub = null;
  540. _cancelUserPushStreamSubscriptionsOnly();
  541. _userPushChannelsRetained = false;
  542. _subscribeWebSocket(symbol);
  543. if (ref.read(isLoggedInProvider)) {
  544. _subscribeUserPushChannels();
  545. }
  546. }
  547. void _subscribeUserPushChannels() {
  548. if (!ref.read(isLoggedInProvider)) return;
  549. final uid = ref.read(profileProvider).user.uid;
  550. if (uid.isEmpty) return;
  551. if (_userPushChannelsRetained) {
  552. try {
  553. final ws = ref.read(spotWsClientProvider);
  554. ws.releaseSpotAssetChannel();
  555. ws.releaseSpotOrderChannel();
  556. } catch (_) {}
  557. _userPushChannelsRetained = false;
  558. }
  559. _cancelUserPushStreamSubscriptionsOnly();
  560. final ws = ref.read(spotWsClientProvider);
  561. ws.retainSpotAssetChannel();
  562. ws.retainSpotOrderChannel();
  563. _userPushChannelsRetained = true;
  564. _assetSub = ws.assetStream.listen(_onAssetPush);
  565. _orderSub = ws.orderStream.listen(_onOrderPush);
  566. }
  567. void _cancelUserPushStreamSubscriptionsOnly() {
  568. _assetSub?.cancel();
  569. _orderSub?.cancel();
  570. _assetSub = null;
  571. _orderSub = null;
  572. }
  573. void _releaseSpotUserPushChannels() {
  574. _cancelUserPushStreamSubscriptionsOnly();
  575. if (!_userPushChannelsRetained) return;
  576. try {
  577. final ws = ref.read(spotWsClientProvider);
  578. ws.releaseSpotAssetChannel();
  579. ws.releaseSpotOrderChannel();
  580. } catch (_) {}
  581. _userPushChannelsRetained = false;
  582. }
  583. void _onAssetPush(Map<String, dynamic> msg) {
  584. final list = msg['accountList'];
  585. if (list is! List) return;
  586. if (list.isEmpty) {
  587. Future.microtask(() => _loadWallets());
  588. return;
  589. }
  590. final merged = _mergeAccountListIntoWallets(state.wallets, list);
  591. state = state.copyWith(wallets: merged);
  592. }
  593. void _onOrderPush(Map<String, dynamic> msg) {
  594. final list = msg['orderList'];
  595. if (list is! List) return;
  596. final oc = msg['orderCount'];
  597. final int? totalPending = oc is int
  598. ? oc
  599. : oc is num
  600. ? oc.toInt()
  601. : int.tryParse(oc?.toString() ?? '');
  602. if (list.isEmpty) {
  603. if (totalPending == 0) {
  604. state = state.copyWith(openOrders: []);
  605. } else {
  606. Future.microtask(() => _loadCurrentOrders());
  607. }
  608. return;
  609. }
  610. final incoming = <SpotOrder>[];
  611. final symbolsTouched = <String>{};
  612. for (final e in list) {
  613. if (e is Map) {
  614. final o = SpotOrder.fromJson(Map<String, dynamic>.from(e));
  615. incoming.add(o);
  616. if (o.symbol.isNotEmpty) {
  617. symbolsTouched.add(o.symbol.toUpperCase());
  618. }
  619. }
  620. }
  621. if (symbolsTouched.isEmpty) {
  622. state = state.copyWith(openOrders: incoming);
  623. return;
  624. }
  625. final kept = [
  626. for (final o in state.openOrders)
  627. if (!symbolsTouched.contains(o.symbol.toUpperCase())) o,
  628. ];
  629. state = state.copyWith(openOrders: [...kept, ...incoming]);
  630. }
  631. List<SpotWalletAsset> _mergeAccountListIntoWallets(
  632. List<SpotWalletAsset> current,
  633. List<dynamic> incoming,
  634. ) {
  635. final byCoin = <String, SpotWalletAsset>{
  636. for (final w in current) w.coin: w,
  637. };
  638. for (final raw in incoming) {
  639. if (raw is! Map) continue;
  640. final a = SpotWalletAsset.fromJson(Map<String, dynamic>.from(raw));
  641. if (a.coin.isEmpty) continue;
  642. byCoin[a.coin] = a;
  643. }
  644. final out = byCoin.values.toList()
  645. ..sort((a, b) => a.coin.compareTo(b.coin));
  646. return out;
  647. }
  648. void _subscribeWebSocket(String symbol) {
  649. final wsSymbol = symbol.replaceAll('/', '').replaceAll('-', '').toLowerCase();
  650. final ws = ref.read(spotWsClientProvider);
  651. ws.subscribeTicker(wsSymbol);
  652. ws.subscribeDepth(wsSymbol);
  653. ws.subscribeTrade(wsSymbol);
  654. _tickerSub?.cancel();
  655. _tickerSub = ws.tickerStream
  656. .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol)
  657. .listen((data) {
  658. final price = (data['price'] as num?)?.toDouble() ?? 0;
  659. final change = (data['change24h'] as num?)?.toDouble() ?? state.change24h;
  660. final priceStr = data['priceStr'] as String? ?? '';
  661. if (price > 0) {
  662. state = state.copyWith(
  663. lastPrice: price,
  664. lastPriceStr: priceStr.isNotEmpty ? priceStr : null,
  665. change24h: change,
  666. );
  667. }
  668. });
  669. _depthSub?.cancel();
  670. _depthSub = ws.depthStream
  671. .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol)
  672. .listen((data) {
  673. if (!data.containsKey('asks') && !data.containsKey('bids')) return;
  674. var asks = state.orderBookAsks;
  675. var bids = state.orderBookBids;
  676. if (data.containsKey('asks')) {
  677. asks = _normalizeDepthMaps(data['asks']);
  678. }
  679. if (data.containsKey('bids')) {
  680. bids = _normalizeDepthMaps(data['bids']);
  681. }
  682. state = state.copyWith(orderBookAsks: asks, orderBookBids: bids);
  683. });
  684. _tradeSub?.cancel();
  685. _tradeSub = ws.tradeStream
  686. .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol)
  687. .listen((data) {
  688. final side = (data['side'] ?? '').toString().toUpperCase();
  689. final trade = SpotPublicTrade(
  690. price: (data['price'] as num?)?.toDouble() ?? 0,
  691. quantity: (data['quantity'] as num?)?.toDouble() ?? 0,
  692. isBuyerMaker: side == 'SELL',
  693. time: data['time'] as int? ?? 0,
  694. tradeId: data['id']?.toString() ?? '',
  695. );
  696. if (trade.price <= 0) return;
  697. final next = [trade, ...state.recentPublicTrades];
  698. if (next.length > 50) next.removeRange(50, next.length);
  699. state = state.copyWith(recentPublicTrades: next);
  700. });
  701. }
  702. static List<Map<String, dynamic>> _normalizeDepthMaps(dynamic raw) {
  703. if (raw is! List) return [];
  704. return raw
  705. .map((e) {
  706. if (e is Map<String, dynamic>) return e;
  707. if (e is Map) return Map<String, dynamic>.from(e);
  708. return <String, dynamic>{};
  709. })
  710. .where((m) => m.isNotEmpty)
  711. .toList();
  712. }
  713. void stopPolling() {}
  714. void resumePolling() {
  715. if (ref.read(isLoggedInProvider)) {
  716. Future.microtask(() async {
  717. try {
  718. await Future.wait([_loadWallets(), _loadCurrentOrders()]);
  719. } catch (_) {}
  720. });
  721. }
  722. }
  723. void _dispose() {
  724. final symbol = state.symbol
  725. .replaceAll('/', '')
  726. .replaceAll('-', '')
  727. .toLowerCase();
  728. try {
  729. final ws = ref.read(spotWsClientProvider);
  730. ws.unsubscribeTicker(symbol);
  731. ws.unsubscribeDepth(symbol);
  732. ws.unsubscribeTrade(symbol);
  733. } catch (_) {}
  734. _tickerSub?.cancel();
  735. _depthSub?.cancel();
  736. _tradeSub?.cancel();
  737. _releaseSpotUserPushChannels();
  738. }
  739. void setSide(SpotSide side) => state = state.copyWith(side: side);
  740. void setOrderType(SpotOrderType type) {
  741. state = state.copyWith(orderType: type, sliderPercent: 0);
  742. }
  743. void setAmountUnit(SpotAmountUnit unit) =>
  744. state = state.copyWith(amountUnit: unit);
  745. void setSliderPercent(double pct) =>
  746. state = state.copyWith(sliderPercent: pct.clamp(0.0, 1.0));
  747. void setActiveTab(SpotTab tab) {
  748. state = state.copyWith(activeTab: tab);
  749. }
  750. void toggleHideOtherSymbols() =>
  751. state = state.copyWith(hideOtherSymbols: !state.hideOtherSymbols);
  752. /// 下单,null 表示成功。
  753. Future<String?> placeOrder({
  754. SpotSide? side,
  755. SpotOrderType? type,
  756. double? price,
  757. required double amount,
  758. }) async {
  759. final actualSide = side ?? state.side;
  760. final actualType = type ?? state.orderType;
  761. if (amount <= 0) return 'errEnterAmount';
  762. if (actualType == SpotOrderType.limit && (price == null || price <= 0)) {
  763. return 'errEnterPrice';
  764. }
  765. // 客户端模拟"市价条件委托":暂未支持
  766. if (actualType == SpotOrderType.conditionalMarket) {
  767. return 'errConditionalNotSupported';
  768. }
  769. try {
  770. await _service.placeOrder(
  771. symbol: state.apiSymbol,
  772. side: actualSide == SpotSide.buy ? 'BUY' : 'SELL',
  773. type: actualType == SpotOrderType.limit ? 1 : 2,
  774. price: actualType == SpotOrderType.limit ? (price ?? 0) : 0,
  775. volume: amount,
  776. );
  777. // 下单后立即刷新
  778. await Future.wait([_loadWallets(), _loadCurrentOrders()]);
  779. // 防止后端异步入账,2 秒后再刷一次
  780. Future.delayed(const Duration(seconds: 2), () {
  781. _loadWallets();
  782. _loadCurrentOrders();
  783. });
  784. return null;
  785. } catch (e) {
  786. return _errMsg(e);
  787. }
  788. }
  789. Future<String?> cancelOrder(SpotOrder order) async {
  790. if (order.id.isEmpty) return 'errInvalidOrderId';
  791. try {
  792. await _service.cancelOrder(int.tryParse(order.id) ?? 0);
  793. await _loadCurrentOrders();
  794. return null;
  795. } catch (e) {
  796. return _errMsg(e);
  797. }
  798. }
  799. Future<String?> cancelAll() async {
  800. final list = state.displayOrders.where((o) => o.isPending).toList();
  801. if (list.isEmpty) return 'errNoOrdersToCancel';
  802. String? lastErr;
  803. for (final o in list) {
  804. final err = await cancelOrder(o);
  805. if (err != null) lastErr = err;
  806. }
  807. return lastErr;
  808. }
  809. /// 划转;direction 1→现货 2→资金,null 成功。
  810. Future<String?> transfer({
  811. required String symbol,
  812. required double amount,
  813. required int direction,
  814. }) async {
  815. try {
  816. await _service.transfer(symbol: symbol, amount: amount, direction: direction);
  817. await _loadWallets();
  818. return null;
  819. } catch (e) {
  820. return _errMsg(e);
  821. }
  822. }
  823. Future<void> refresh() async {
  824. await Future.wait([_loadWallets(), _loadCurrentOrders()]);
  825. await _loadOrderBook(state.symbol);
  826. }
  827. ({double payload, double display})? prepareAmount({
  828. required SpotSide side,
  829. required SpotOrderType type,
  830. required double inputAmount,
  831. required SpotAmountUnit unit,
  832. double? price, // 限价时用户输入价格
  833. }) {
  834. if (inputAmount <= 0) return null;
  835. final volPre = state.volumePrecision;
  836. final factor = math.pow(10, volPre).toDouble();
  837. if (type == SpotOrderType.market || type == SpotOrderType.conditionalMarket) {
  838. if (side == SpotSide.buy) {
  839. // 市价买:按 USDT 金额下单
  840. return (payload: inputAmount, display: inputAmount);
  841. }
  842. // 市价卖:按 base 数量下单
  843. final base = (inputAmount * factor).floorToDouble() / factor;
  844. return (payload: base, display: base);
  845. }
  846. // 限价:amount 始终是 base 数量
  847. final p = price ?? 0;
  848. if (unit == SpotAmountUnit.base) {
  849. final v = (inputAmount * factor).floorToDouble() / factor;
  850. return (payload: v, display: v);
  851. }
  852. // quote → 换算为 base
  853. if (p <= 0) return null;
  854. final v = (inputAmount / p * factor).floorToDouble() / factor;
  855. return (payload: v, display: v);
  856. }
  857. String _errMsg(Object e) {
  858. if (e is ApiException) return e.message;
  859. if (e is DioException) {
  860. final inner = e.error;
  861. if (inner is ApiException) return inner.message;
  862. final data = e.response?.data;
  863. if (data is Map) {
  864. final msg = data['message'] as String? ?? data['msg'] as String?;
  865. if (msg != null && msg.isNotEmpty) return msg;
  866. }
  867. if (e.type == DioExceptionType.connectionTimeout ||
  868. e.type == DioExceptionType.receiveTimeout ||
  869. e.type == DioExceptionType.sendTimeout) {
  870. return 'errTimeout';
  871. }
  872. if (e.type == DioExceptionType.connectionError) {
  873. return 'errNetworkError';
  874. }
  875. }
  876. final s = e.toString();
  877. final m = RegExp(r'ApiException\(\d+\):\s*(.+?)[\r\n]?$', multiLine: true)
  878. .firstMatch(s);
  879. if (m != null) return m.group(1)!.trim();
  880. return s;
  881. }
  882. }
  883. final spotProvider =
  884. AutoDisposeNotifierProviderFamily<SpotNotifier, SpotState, String>(
  885. SpotNotifier.new,
  886. );
  887. final spotActiveSymbolProvider = StateProvider<String>((ref) => '');
  888. double _toDouble(dynamic v) {
  889. if (v == null) return 0.0;
  890. if (v is num) return v.toDouble();
  891. return double.tryParse(v.toString()) ?? 0.0;
  892. }
  893. int? _toInt(dynamic v) {
  894. if (v == null) return null;
  895. if (v is num) return v.toInt();
  896. return int.tryParse(v.toString());
  897. }
  898. // ignore: unused_element
  899. const _kDebugProvider = false;
  900. // ignore: unused_element
  901. void _dlog(String msg) {
  902. if (_kDebugProvider) debugPrint('[SpotProvider] $msg');
  903. }