| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003 |
- 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<Map<String, dynamic>> orderBookAsks;
- final List<Map<String, dynamic>> orderBookBids;
- final List<SpotPublicTrade> recentPublicTrades;
- // 钱包
- final List<SpotWalletAsset> wallets;
- final double totalAmount; // USDT 总估值
- final double todayPnl;
- final double todayPnlRate;
- // 委托
- final List<SpotOrder> 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<SpotOrder> 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<Map<String, dynamic>>? orderBookAsks,
- List<Map<String, dynamic>>? orderBookBids,
- List<SpotPublicTrade>? recentPublicTrades,
- List<SpotWalletAsset>? wallets,
- double? totalAmount,
- double? todayPnl,
- double? todayPnlRate,
- List<SpotOrder>? 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<SpotState, String> {
- StreamSubscription<Map<String, dynamic>>? _tickerSub;
- StreamSubscription<Map<String, dynamic>>? _depthSub;
- StreamSubscription<Map<String, dynamic>>? _tradeSub;
- StreamSubscription<Map<String, dynamic>>? _assetSub;
- StreamSubscription<Map<String, dynamic>>? _orderSub;
- bool _userPushChannelsRetained = false;
- SpotService get _service => SpotService(ref.read(dioClientProvider));
- @override
- SpotState build(String symbol) {
- ref.onDispose(_dispose);
- ref.listen<SpotWsClient>(spotWsClientProvider, (prev, next) {
- if (prev != null && !identical(prev, next)) {
- Future.microtask(() => _bindAllStreams(symbol));
- }
- });
- ref.listen<AsyncValue<SpotWsState>>(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<bool>(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<void> _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<void> _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<void> _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<String, dynamic>>()
- .map(SpotWalletAsset.fromJson)
- .toList();
- state = state.copyWith(
- wallets: wallets,
- totalAmount: _toDouble(data['totalAmount']),
- todayPnl: _toDouble(data['todayPnl']),
- todayPnlRate: _toDouble(data['todayPnlRate']),
- );
- }
- } catch (_) {}
- }
- Future<void> _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<String, dynamic>>()
- .map(SpotOrder.fromJson)
- .toList();
- state = state.copyWith(openOrders: orders);
- }
- } catch (_) {}
- }
- Future<void> _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<String, dynamic> 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<String, dynamic> 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 = <SpotOrder>[];
- final symbolsTouched = <String>{};
- for (final e in list) {
- if (e is Map) {
- final o = SpotOrder.fromJson(Map<String, dynamic>.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<SpotWalletAsset> _mergeAccountListIntoWallets(
- List<SpotWalletAsset> current,
- List<dynamic> incoming,
- ) {
- final byCoin = <String, SpotWalletAsset>{
- for (final w in current) w.coin: w,
- };
- for (final raw in incoming) {
- if (raw is! Map) continue;
- final a = SpotWalletAsset.fromJson(Map<String, dynamic>.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<Map<String, dynamic>> _normalizeDepthMaps(dynamic raw) {
- if (raw is! List) return [];
- return raw
- .map((e) {
- if (e is Map<String, dynamic>) return e;
- if (e is Map) return Map<String, dynamic>.from(e);
- return <String, dynamic>{};
- })
- .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<String?> 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<String?> 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<String?> 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<String?> 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<void> 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, SpotState, String>(
- SpotNotifier.new,
- );
- final spotActiveSymbolProvider = StateProvider<String>((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');
- }
|