import 'dart:async'; import 'dart:math' as math; import 'package:dio/dio.dart' show DioException, DioExceptionType; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/network/api_response.dart'; import '../core/network/dio_client.dart'; import '../core/network/spot_ws_client.dart'; import '../data/services/spot_service.dart'; import 'auth_provider.dart'; import 'profile_provider.dart'; import 'spot_ws_provider.dart'; // 枚举 /// 买卖方向 enum SpotSide { buy, sell } /// 限价 / 市价 / 条件市价(条件为客户端占位) enum SpotOrderType { limit, market, conditionalMarket } /// 交易页底栏:委托 / 资产 enum SpotTab { orders, assets } /// 数量单位:base 或 quote(市价买固定 quote) enum SpotAmountUnit { base, quote } // 数据模型 class SpotSymbolInfo { final String symbol; // 如 BTC/USDT final String base; // BTC final String quote; // USDT final int pricePrecision; final int volumePrecision; final int depth0Pre; final int depth1Pre; final int depth2Pre; final double minLimitPrice; final double minLimitVolume; final double minMarketBuy; final double minMarketSell; final bool enableMarketBuy; final bool enableMarketSell; final double makerFee; final double takerFee; const SpotSymbolInfo({ required this.symbol, required this.base, required this.quote, this.pricePrecision = 2, this.volumePrecision = 4, this.depth0Pre = 2, this.depth1Pre = 4, this.depth2Pre = 6, this.minLimitPrice = 0, this.minLimitVolume = 0, this.minMarketBuy = 0, this.minMarketSell = 0, this.enableMarketBuy = true, this.enableMarketSell = true, this.makerFee = 0.001, this.takerFee = 0.001, }); factory SpotSymbolInfo.fromJson(Map json) { final sym = (json['symbol'] as String? ?? '').toUpperCase(); final base = (json['base'] as String? ?? '').toUpperCase(); final quote = (json['quote'] as String? ?? 'USDT').toUpperCase(); return SpotSymbolInfo( symbol: sym, base: base.isNotEmpty ? base : _deriveBaseFallback(sym), quote: quote.isNotEmpty ? quote : 'USDT', pricePrecision: _toInt(json['pricePre']) ?? 2, volumePrecision: _toInt(json['volumePre']) ?? 4, depth0Pre: _toInt(json['depth0Pre']) ?? 2, depth1Pre: _toInt(json['depth1Pre']) ?? 4, depth2Pre: _toInt(json['depth2Pre']) ?? 6, minLimitPrice: _toDouble(json['limitPriceMin']), minLimitVolume: _toDouble(json['limitVolumeMin']), minMarketBuy: _toDouble(json['marketBuyMin']), minMarketSell: _toDouble(json['marketSellMin']), enableMarketBuy: (json['marketBuyMin'] as num? ?? 0) > 0, enableMarketSell: (json['marketSellMin'] as num? ?? 0) > 0, makerFee: _toDouble(json['makerFee']), takerFee: _toDouble(json['takerFee']), ); } static String _deriveBaseFallback(String sym) { for (final q in const ['USDT', 'BTC', 'ETH', 'BUSD']) { if (sym.endsWith(q) && sym.length > q.length) { return sym.substring(0, sym.length - q.length); } } return sym; } } class SpotWalletAsset { final String coin; final double balance; final double frozenBalance; const SpotWalletAsset({ required this.coin, required this.balance, required this.frozenBalance, }); double get total => balance + frozenBalance; factory SpotWalletAsset.fromJson(Map json) { final coin = (json['symbol'] ?? json['coinId'] ?? json['coin']) ?.toString() .toUpperCase() ?? ''; return SpotWalletAsset( coin: coin, balance: _toDouble(json['available'] ?? json['balance']), frozenBalance: _toDouble(json['frozen'] ?? json['frozenBalance']), ); } } class SpotOrder { final String id; final String symbol; // BTCUSDT final SpotSide side; final SpotOrderType type; /// 后端 type:1 限价 2 市价 3 条件 final int typeCode; /// 后端 status,未知 -1 final int statusCode; final double price; // 限价委托价 final double amount; // 委托量(市价买为 USDT 金额) final double tradedAmount; final double tradedTurnover; final double avgPrice; final String status; // 文本:委托中/部分成交/已成交/已撤销 final DateTime? createTime; const SpotOrder({ required this.id, required this.symbol, required this.side, required this.type, this.typeCode = 1, this.statusCode = -1, required this.price, required this.amount, this.tradedAmount = 0, this.tradedTurnover = 0, this.avgPrice = 0, this.status = '', this.createTime, }); bool get isPending => status == '委托中' || status == '部分成交'; factory SpotOrder.fromJson(Map json) { final dirRaw = (json['side'] ?? json['direction'] ?? 'BUY').toString().toUpperCase(); final side = dirRaw == 'BUY' ? SpotSide.buy : SpotSide.sell; final typeRaw = json['type']; int typeCode = 1; if (typeRaw is int) { typeCode = typeRaw; } else if (typeRaw is String) { final tr = typeRaw.toUpperCase(); if (tr == 'MARKET_PRICE' || tr == 'MARKET') { typeCode = 2; } else if (tr == 'STOP' || tr == 'STOP_LOSS') { typeCode = 3; } else { typeCode = int.tryParse(typeRaw) ?? 1; } } final SpotOrderType type; if (typeCode == 2 || typeRaw == 'MARKET_PRICE' || typeRaw == 'MARKET') { type = SpotOrderType.market; } else if (typeCode == 3) { type = SpotOrderType.conditionalMarket; } else { type = SpotOrderType.limit; } final ts = json['ctime'] ?? json['time'] ?? json['createTime']; DateTime? createTime; if (ts is int && ts > 0) { createTime = DateTime.fromMillisecondsSinceEpoch( ts > 9999999999 ? ts : ts * 1000, ); } else if (ts is String) { createTime = DateTime.tryParse(ts); } final statusRaw = json['status']; final statusStr = statusRaw is String ? statusRaw : statusRaw?.toString() ?? ''; final statusCode = _parseStatusCode(statusRaw, statusStr); final status = _mapStatus(statusStr); return SpotOrder( id: (json['id'] ?? json['orderId'] ?? '').toString(), symbol: (json['symbol'] as String? ?? '').toUpperCase(), side: side, type: type, typeCode: typeCode, statusCode: statusCode, price: _toDouble(json['price']), amount: _toDouble(json['volume'] ?? json['amount']), tradedAmount: _toDouble(json['dealVolume'] ?? json['tradedAmount']), tradedTurnover: _toDouble(json['dealMoney'] ?? json['turnover']), avgPrice: _toDouble(json['avgPrice'] ?? json['tradedAvgPrice']), status: status, createTime: createTime, ); } static int _parseStatusCode(dynamic statusRaw, String statusStr) { if (statusRaw is int) return statusRaw; final n = int.tryParse(statusStr); if (n != null) return n; switch (statusStr.toUpperCase()) { case 'INIT': case 'NEW': case 'TRADING': return 1; case 'FILLED': case 'COMPLETED': return 2; case 'PART_FILLED': case 'PART_TRADED': return 3; case 'CANCELED': case 'CANCELLED': return 4; case 'PENDING_CANCEL': return 5; case 'EXPIRED': case 'OVERTIMED': return 6; default: return -1; } } static String _mapStatus(String raw) { switch (raw.toUpperCase()) { case '0': case '1': return '委托中'; case '3': return '部分成交'; case '2': return '已成交'; case '4': case '5': case '6': return '已撤销'; case 'TRADING': case 'NEW': case 'INIT': return '委托中'; case 'PART_FILLED': case 'PART_TRADED': return '部分成交'; case 'COMPLETED': case 'FILLED': return '已成交'; case 'CANCELED': case 'CANCELLED': case 'OVERTIMED': return '已撤销'; default: return raw; } } } /// trade_ticker 成交一条 class SpotPublicTrade { const SpotPublicTrade({ required this.price, required this.quantity, required this.isBuyerMaker, required this.time, this.tradeId = '', }); final double price; final double quantity; final bool isBuyerMaker; final int time; final String tradeId; } class SpotState { /// 交易对,如 BTCUSDT final String symbol; final SpotSymbolInfo? info; // 行情 final double lastPrice; final String? lastPriceStr; // WS 返回的原始价格字符串 final double change24h; final List> orderBookAsks; final List> orderBookBids; final List recentPublicTrades; // 钱包 final List wallets; final double totalAmount; // USDT 总估值 final double todayPnl; final double todayPnlRate; // 委托 final List openOrders; final bool ordersHasMore; final int ordersPage; // 表单 final SpotSide side; final SpotOrderType orderType; final SpotAmountUnit amountUnit; // 限价单可切换;市价买默认 quote(USDT),市价卖默认 base final double sliderPercent; // 0..1 // UI 状态 final SpotTab activeTab; final bool isLoading; // 首屏骨架 final bool isTabLoading; // 列表加载 final bool hideOtherSymbols; const SpotState({ required this.symbol, this.info, this.lastPrice = 0, this.lastPriceStr, this.change24h = 0, this.orderBookAsks = const [], this.orderBookBids = const [], this.recentPublicTrades = const [], this.wallets = const [], this.totalAmount = 0, this.todayPnl = 0, this.todayPnlRate = 0, this.openOrders = const [], this.ordersHasMore = false, this.ordersPage = 1, this.side = SpotSide.buy, this.orderType = SpotOrderType.market, this.amountUnit = SpotAmountUnit.quote, this.sliderPercent = 0, this.activeTab = SpotTab.orders, this.isLoading = true, this.isTabLoading = false, this.hideOtherSymbols = false, }); String get apiSymbol => symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase(); String get baseCoin => info?.base ?? _deriveBase(symbol); String get quoteCoin => info?.quote ?? 'USDT'; int get pricePrecision => info?.pricePrecision ?? 2; int get volumePrecision => info?.volumePrecision ?? 4; int get depth0Pre => info?.depth0Pre ?? 2; int get depth1Pre => info?.depth1Pre ?? 4; int get depth2Pre => info?.depth2Pre ?? 6; SpotAmountUnit get effectiveAmountUnit { if (orderType == SpotOrderType.limit) return amountUnit; return side == SpotSide.buy ? SpotAmountUnit.quote : SpotAmountUnit.base; } bool get showPriceInput => orderType == SpotOrderType.limit; bool get showTriggerPrice => orderType == SpotOrderType.conditionalMarket; /// 可用 USDT double get availableQuote { final w = wallets.firstWhere( (a) => a.coin == quoteCoin, orElse: () => SpotWalletAsset(coin: quoteCoin, balance: 0, frozenBalance: 0), ); return w.balance; } /// 可用 base double get availableBase { final w = wallets.firstWhere( (a) => a.coin == baseCoin, orElse: () => SpotWalletAsset(coin: baseCoin, balance: 0, frozenBalance: 0), ); return w.balance; } /// openOrders 为全量;开启「隐藏其他」时按本交易对过滤。 List get displayOrders { if (!hideOtherSymbols) return openOrders; return openOrders.where((o) => o.symbol == apiSymbol).toList(); } static String _deriveBase(String s) { final up = s.toUpperCase().replaceAll('/', '').replaceAll('-', ''); for (final q in const ['USDT', 'BTC', 'ETH', 'BUSD']) { if (up.endsWith(q) && up.length > q.length) { return up.substring(0, up.length - q.length); } } return up; } SpotState copyWith({ String? symbol, SpotSymbolInfo? info, double? lastPrice, String? lastPriceStr, double? change24h, List>? orderBookAsks, List>? orderBookBids, List? recentPublicTrades, List? wallets, double? totalAmount, double? todayPnl, double? todayPnlRate, List? openOrders, bool? ordersHasMore, int? ordersPage, SpotSide? side, SpotOrderType? orderType, SpotAmountUnit? amountUnit, double? sliderPercent, SpotTab? activeTab, bool? isLoading, bool? isTabLoading, bool? hideOtherSymbols, }) { return SpotState( symbol: symbol ?? this.symbol, info: info ?? this.info, lastPrice: lastPrice ?? this.lastPrice, lastPriceStr: lastPriceStr ?? this.lastPriceStr, change24h: change24h ?? this.change24h, orderBookAsks: orderBookAsks ?? this.orderBookAsks, orderBookBids: orderBookBids ?? this.orderBookBids, recentPublicTrades: recentPublicTrades ?? this.recentPublicTrades, wallets: wallets ?? this.wallets, totalAmount: totalAmount ?? this.totalAmount, todayPnl: todayPnl ?? this.todayPnl, todayPnlRate: todayPnlRate ?? this.todayPnlRate, openOrders: openOrders ?? this.openOrders, ordersHasMore: ordersHasMore ?? this.ordersHasMore, ordersPage: ordersPage ?? this.ordersPage, side: side ?? this.side, orderType: orderType ?? this.orderType, amountUnit: amountUnit ?? this.amountUnit, sliderPercent: sliderPercent ?? this.sliderPercent, activeTab: activeTab ?? this.activeTab, isLoading: isLoading ?? this.isLoading, isTabLoading: isTabLoading ?? this.isTabLoading, hideOtherSymbols: hideOtherSymbols ?? this.hideOtherSymbols, ); } } // Notifier class SpotNotifier extends AutoDisposeFamilyNotifier { StreamSubscription>? _tickerSub; StreamSubscription>? _depthSub; StreamSubscription>? _tradeSub; StreamSubscription>? _assetSub; StreamSubscription>? _orderSub; bool _userPushChannelsRetained = false; SpotService get _service => SpotService(ref.read(dioClientProvider)); @override SpotState build(String symbol) { ref.onDispose(_dispose); ref.listen(spotWsClientProvider, (prev, next) { if (prev != null && !identical(prev, next)) { Future.microtask(() => _bindAllStreams(symbol)); } }); ref.listen>(spotWsConnectionStateProvider, (prev, next) { final s = next.valueOrNull; if (s != SpotWsState.connected) return; if (!ref.read(isLoggedInProvider)) return; if (prev?.valueOrNull == SpotWsState.reconnecting) { Future.microtask(() async { try { await Future.wait([_loadWallets(), _loadCurrentOrders()]); } catch (_) {} }); } }); Future.microtask(() => _init(symbol)); ref.listen(isLoggedInProvider, (prev, loggedIn) { if (loggedIn) { Future.microtask(() async { try { await Future.wait([_loadWallets(), _loadCurrentOrders()]); } catch (_) {} _subscribeUserPushChannels(); }); } else { _releaseSpotUserPushChannels(); state = state.copyWith( wallets: const [], openOrders: const [], ); } }); return SpotState(symbol: symbol, isLoading: true); } Future _init(String symbol) async { await _loadSymbolInfo(symbol); _subscribeWebSocket(symbol); state = state.copyWith(isLoading: false, isTabLoading: true); if (ref.read(isLoggedInProvider)) { try { await Future.wait([_loadWallets(), _loadCurrentOrders()]); } catch (_) {} _subscribeUserPushChannels(); } state = state.copyWith(isTabLoading: false); } Future _loadSymbolInfo(String symbol) async { try { final list = await _service.getSymbols(); final sym = symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase(); final match = list.where((m) { final s = (m['symbol'] as String? ?? '').toUpperCase(); return s == sym; }).firstOrNull; if (match != null) { state = state.copyWith(info: SpotSymbolInfo.fromJson(match)); } } catch (_) {} } Future _loadWallets() async { if (!ref.read(isLoggedInProvider)) return; try { final data = await _service.getAssets(); final list = data['assetList']; if (list is List) { final wallets = list .whereType>() .map(SpotWalletAsset.fromJson) .toList(); state = state.copyWith( wallets: wallets, totalAmount: _toDouble(data['totalAmount']), todayPnl: _toDouble(data['todayPnl']), todayPnlRate: _toDouble(data['todayPnlRate']), ); } } catch (_) {} } Future _loadCurrentOrders() async { if (!ref.read(isLoggedInProvider)) return; try { final data = await _service.getCurrentOrders(); final records = data['records']; if (records is List) { final orders = records .whereType>() .map(SpotOrder.fromJson) .toList(); state = state.copyWith(openOrders: orders); } } catch (_) {} } Future _loadOrderBook(String symbol) async {} void _bindAllStreams(String symbol) { _tickerSub?.cancel(); _depthSub?.cancel(); _tradeSub?.cancel(); _tickerSub = null; _depthSub = null; _tradeSub = null; _cancelUserPushStreamSubscriptionsOnly(); _userPushChannelsRetained = false; _subscribeWebSocket(symbol); if (ref.read(isLoggedInProvider)) { _subscribeUserPushChannels(); } } void _subscribeUserPushChannels() { if (!ref.read(isLoggedInProvider)) return; final uid = ref.read(profileProvider).user.uid; if (uid.isEmpty) return; if (_userPushChannelsRetained) { try { final ws = ref.read(spotWsClientProvider); ws.releaseSpotAssetChannel(); ws.releaseSpotOrderChannel(); } catch (_) {} _userPushChannelsRetained = false; } _cancelUserPushStreamSubscriptionsOnly(); final ws = ref.read(spotWsClientProvider); ws.retainSpotAssetChannel(); ws.retainSpotOrderChannel(); _userPushChannelsRetained = true; _assetSub = ws.assetStream.listen(_onAssetPush); _orderSub = ws.orderStream.listen(_onOrderPush); } void _cancelUserPushStreamSubscriptionsOnly() { _assetSub?.cancel(); _orderSub?.cancel(); _assetSub = null; _orderSub = null; } void _releaseSpotUserPushChannels() { _cancelUserPushStreamSubscriptionsOnly(); if (!_userPushChannelsRetained) return; try { final ws = ref.read(spotWsClientProvider); ws.releaseSpotAssetChannel(); ws.releaseSpotOrderChannel(); } catch (_) {} _userPushChannelsRetained = false; } void _onAssetPush(Map msg) { final list = msg['accountList']; if (list is! List) return; if (list.isEmpty) { Future.microtask(() => _loadWallets()); return; } final merged = _mergeAccountListIntoWallets(state.wallets, list); state = state.copyWith(wallets: merged); } void _onOrderPush(Map msg) { final list = msg['orderList']; if (list is! List) return; final oc = msg['orderCount']; final int? totalPending = oc is int ? oc : oc is num ? oc.toInt() : int.tryParse(oc?.toString() ?? ''); if (list.isEmpty) { if (totalPending == 0) { state = state.copyWith(openOrders: []); } else { Future.microtask(() => _loadCurrentOrders()); } return; } final incoming = []; final symbolsTouched = {}; for (final e in list) { if (e is Map) { final o = SpotOrder.fromJson(Map.from(e)); incoming.add(o); if (o.symbol.isNotEmpty) { symbolsTouched.add(o.symbol.toUpperCase()); } } } if (symbolsTouched.isEmpty) { state = state.copyWith(openOrders: incoming); return; } final kept = [ for (final o in state.openOrders) if (!symbolsTouched.contains(o.symbol.toUpperCase())) o, ]; state = state.copyWith(openOrders: [...kept, ...incoming]); } List _mergeAccountListIntoWallets( List current, List incoming, ) { final byCoin = { for (final w in current) w.coin: w, }; for (final raw in incoming) { if (raw is! Map) continue; final a = SpotWalletAsset.fromJson(Map.from(raw)); if (a.coin.isEmpty) continue; byCoin[a.coin] = a; } final out = byCoin.values.toList() ..sort((a, b) => a.coin.compareTo(b.coin)); return out; } void _subscribeWebSocket(String symbol) { final wsSymbol = symbol.replaceAll('/', '').replaceAll('-', '').toLowerCase(); final ws = ref.read(spotWsClientProvider); ws.subscribeTicker(wsSymbol); ws.subscribeDepth(wsSymbol); ws.subscribeTrade(wsSymbol); _tickerSub?.cancel(); _tickerSub = ws.tickerStream .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol) .listen((data) { final price = (data['price'] as num?)?.toDouble() ?? 0; final change = (data['change24h'] as num?)?.toDouble() ?? state.change24h; final priceStr = data['priceStr'] as String? ?? ''; if (price > 0) { state = state.copyWith( lastPrice: price, lastPriceStr: priceStr.isNotEmpty ? priceStr : null, change24h: change, ); } }); _depthSub?.cancel(); _depthSub = ws.depthStream .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol) .listen((data) { if (!data.containsKey('asks') && !data.containsKey('bids')) return; var asks = state.orderBookAsks; var bids = state.orderBookBids; if (data.containsKey('asks')) { asks = _normalizeDepthMaps(data['asks']); } if (data.containsKey('bids')) { bids = _normalizeDepthMaps(data['bids']); } state = state.copyWith(orderBookAsks: asks, orderBookBids: bids); }); _tradeSub?.cancel(); _tradeSub = ws.tradeStream .where((d) => (d['symbol'] as String? ?? '').toLowerCase() == wsSymbol) .listen((data) { final side = (data['side'] ?? '').toString().toUpperCase(); final trade = SpotPublicTrade( price: (data['price'] as num?)?.toDouble() ?? 0, quantity: (data['quantity'] as num?)?.toDouble() ?? 0, isBuyerMaker: side == 'SELL', time: data['time'] as int? ?? 0, tradeId: data['id']?.toString() ?? '', ); if (trade.price <= 0) return; final next = [trade, ...state.recentPublicTrades]; if (next.length > 50) next.removeRange(50, next.length); state = state.copyWith(recentPublicTrades: next); }); } static List> _normalizeDepthMaps(dynamic raw) { if (raw is! List) return []; return raw .map((e) { if (e is Map) return e; if (e is Map) return Map.from(e); return {}; }) .where((m) => m.isNotEmpty) .toList(); } void stopPolling() {} void resumePolling() { if (ref.read(isLoggedInProvider)) { Future.microtask(() async { try { await Future.wait([_loadWallets(), _loadCurrentOrders()]); } catch (_) {} }); } } void _dispose() { final symbol = state.symbol .replaceAll('/', '') .replaceAll('-', '') .toLowerCase(); try { final ws = ref.read(spotWsClientProvider); ws.unsubscribeTicker(symbol); ws.unsubscribeDepth(symbol); ws.unsubscribeTrade(symbol); } catch (_) {} _tickerSub?.cancel(); _depthSub?.cancel(); _tradeSub?.cancel(); _releaseSpotUserPushChannels(); } void setSide(SpotSide side) => state = state.copyWith(side: side); void setOrderType(SpotOrderType type) { state = state.copyWith(orderType: type, sliderPercent: 0); } void setAmountUnit(SpotAmountUnit unit) => state = state.copyWith(amountUnit: unit); void setSliderPercent(double pct) => state = state.copyWith(sliderPercent: pct.clamp(0.0, 1.0)); void setActiveTab(SpotTab tab) { state = state.copyWith(activeTab: tab); } void toggleHideOtherSymbols() => state = state.copyWith(hideOtherSymbols: !state.hideOtherSymbols); /// 下单,null 表示成功。 Future placeOrder({ SpotSide? side, SpotOrderType? type, double? price, required double amount, }) async { final actualSide = side ?? state.side; final actualType = type ?? state.orderType; if (amount <= 0) return 'errEnterAmount'; if (actualType == SpotOrderType.limit && (price == null || price <= 0)) { return 'errEnterPrice'; } // 客户端模拟"市价条件委托":暂未支持 if (actualType == SpotOrderType.conditionalMarket) { return 'errConditionalNotSupported'; } try { await _service.placeOrder( symbol: state.apiSymbol, side: actualSide == SpotSide.buy ? 'BUY' : 'SELL', type: actualType == SpotOrderType.limit ? 1 : 2, price: actualType == SpotOrderType.limit ? (price ?? 0) : 0, volume: amount, ); // 下单后立即刷新 await Future.wait([_loadWallets(), _loadCurrentOrders()]); // 防止后端异步入账,2 秒后再刷一次 Future.delayed(const Duration(seconds: 2), () { _loadWallets(); _loadCurrentOrders(); }); return null; } catch (e) { return _errMsg(e); } } Future cancelOrder(SpotOrder order) async { if (order.id.isEmpty) return 'errInvalidOrderId'; try { await _service.cancelOrder(int.tryParse(order.id) ?? 0); await _loadCurrentOrders(); return null; } catch (e) { return _errMsg(e); } } Future cancelAll() async { final list = state.displayOrders.where((o) => o.isPending).toList(); if (list.isEmpty) return 'errNoOrdersToCancel'; String? lastErr; for (final o in list) { final err = await cancelOrder(o); if (err != null) lastErr = err; } return lastErr; } /// 划转;direction 1→现货 2→资金,null 成功。 Future transfer({ required String symbol, required double amount, required int direction, }) async { try { await _service.transfer(symbol: symbol, amount: amount, direction: direction); await _loadWallets(); return null; } catch (e) { return _errMsg(e); } } Future refresh() async { await Future.wait([_loadWallets(), _loadCurrentOrders()]); await _loadOrderBook(state.symbol); } ({double payload, double display})? prepareAmount({ required SpotSide side, required SpotOrderType type, required double inputAmount, required SpotAmountUnit unit, double? price, // 限价时用户输入价格 }) { if (inputAmount <= 0) return null; final volPre = state.volumePrecision; final factor = math.pow(10, volPre).toDouble(); if (type == SpotOrderType.market || type == SpotOrderType.conditionalMarket) { if (side == SpotSide.buy) { // 市价买:按 USDT 金额下单 return (payload: inputAmount, display: inputAmount); } // 市价卖:按 base 数量下单 final base = (inputAmount * factor).floorToDouble() / factor; return (payload: base, display: base); } // 限价:amount 始终是 base 数量 final p = price ?? 0; if (unit == SpotAmountUnit.base) { final v = (inputAmount * factor).floorToDouble() / factor; return (payload: v, display: v); } // quote → 换算为 base if (p <= 0) return null; final v = (inputAmount / p * factor).floorToDouble() / factor; return (payload: v, display: v); } String _errMsg(Object e) { if (e is ApiException) return e.message; if (e is DioException) { final inner = e.error; if (inner is ApiException) return inner.message; final data = e.response?.data; if (data is Map) { final msg = data['message'] as String? ?? data['msg'] as String?; if (msg != null && msg.isNotEmpty) return msg; } if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.receiveTimeout || e.type == DioExceptionType.sendTimeout) { return 'errTimeout'; } if (e.type == DioExceptionType.connectionError) { return 'errNetworkError'; } } final s = e.toString(); final m = RegExp(r'ApiException\(\d+\):\s*(.+?)[\r\n]?$', multiLine: true) .firstMatch(s); if (m != null) return m.group(1)!.trim(); return s; } } final spotProvider = AutoDisposeNotifierProviderFamily( SpotNotifier.new, ); final spotActiveSymbolProvider = StateProvider((ref) => ''); double _toDouble(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } int? _toInt(dynamic v) { if (v == null) return null; if (v is num) return v.toInt(); return int.tryParse(v.toString()); } // ignore: unused_element const _kDebugProvider = false; // ignore: unused_element void _dlog(String msg) { if (_kDebugProvider) debugPrint('[SpotProvider] $msg'); }