transfer_provider.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import 'package:decimal/decimal.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../core/network/dio_client.dart';
  4. import '../core/utils/dialog_utils.dart';
  5. import '../core/utils/spot_transfer_asset.dart';
  6. import '../core/utils/transfer_pair.dart';
  7. import '../data/models/asset/transfer_record.dart';
  8. import '../data/models/asset/today_pnl.dart';
  9. import '../data/services/asset_service.dart';
  10. import '../data/services/spot_service.dart';
  11. class TransferInitOptions {
  12. const TransferInitOptions({
  13. this.from,
  14. this.to,
  15. this.defaultSymbol = 'USDT',
  16. this.preferDefaultSymbol = false,
  17. this.spotTradingBridgeOnly = false,
  18. });
  19. final String? from;
  20. final String? to;
  21. final String defaultSymbol;
  22. final bool preferDefaultSymbol;
  23. final bool spotTradingBridgeOnly;
  24. }
  25. class TransferState {
  26. final String fromType;
  27. final String toType;
  28. final bool spotTradingBridgeOnly;
  29. final bool preferDefaultSymbol;
  30. final List<String> openCoins;
  31. final Map<String, String> fundAvailable;
  32. final Map<String, String> spotAvailable;
  33. final Map<String, String> legacyBalances;
  34. final String spotTradingUsdtBalance;
  35. final String selectedCoin;
  36. final bool isLoading;
  37. final bool isSubmitting;
  38. final bool isLoadingCoins;
  39. final String? errorMessage;
  40. const TransferState({
  41. this.fromType = kWalletSpot,
  42. this.toType = kWalletSwap,
  43. this.spotTradingBridgeOnly = false,
  44. this.preferDefaultSymbol = false,
  45. this.openCoins = const ['USDT'],
  46. this.fundAvailable = const {},
  47. this.spotAvailable = const {},
  48. this.legacyBalances = const {},
  49. this.spotTradingUsdtBalance = '0',
  50. this.selectedCoin = 'USDT',
  51. this.isLoading = false,
  52. this.isSubmitting = false,
  53. this.isLoadingCoins = false,
  54. this.errorMessage,
  55. });
  56. bool get isSpotCoinTransfer =>
  57. involvesSpotTradingBridge(fromType, toType);
  58. String get displayCoinUnit => isSpotCoinTransfer ? selectedCoin : 'USDT';
  59. List<String> get transferOtherTypes {
  60. if (spotTradingBridgeOnly) {
  61. return const [kWalletSpotTrading];
  62. }
  63. return kOtherWalletTypes;
  64. }
  65. Decimal get fromBalance {
  66. if (isSpotCoinTransfer) {
  67. if (fromType == kWalletSpotTrading) {
  68. return _d(spotAvailable[selectedCoin.toUpperCase()] ?? '0');
  69. }
  70. if (fromType == kWalletSpot) {
  71. return _d(fundAvailable[selectedCoin.toUpperCase()] ?? '0');
  72. }
  73. }
  74. if (fromType == kWalletSpotTrading) {
  75. return _d(spotTradingUsdtBalance);
  76. }
  77. return _d(legacyBalances[fromType] ?? '0');
  78. }
  79. TransferState copyWith({
  80. String? fromType,
  81. String? toType,
  82. bool? spotTradingBridgeOnly,
  83. bool? preferDefaultSymbol,
  84. List<String>? openCoins,
  85. Map<String, String>? fundAvailable,
  86. Map<String, String>? spotAvailable,
  87. Map<String, String>? legacyBalances,
  88. String? spotTradingUsdtBalance,
  89. String? selectedCoin,
  90. bool? isLoading,
  91. bool? isSubmitting,
  92. bool? isLoadingCoins,
  93. String? errorMessage,
  94. }) {
  95. return TransferState(
  96. fromType: fromType ?? this.fromType,
  97. toType: toType ?? this.toType,
  98. spotTradingBridgeOnly:
  99. spotTradingBridgeOnly ?? this.spotTradingBridgeOnly,
  100. preferDefaultSymbol: preferDefaultSymbol ?? this.preferDefaultSymbol,
  101. openCoins: openCoins ?? this.openCoins,
  102. fundAvailable: fundAvailable ?? this.fundAvailable,
  103. spotAvailable: spotAvailable ?? this.spotAvailable,
  104. legacyBalances: legacyBalances ?? this.legacyBalances,
  105. spotTradingUsdtBalance:
  106. spotTradingUsdtBalance ?? this.spotTradingUsdtBalance,
  107. selectedCoin: selectedCoin ?? this.selectedCoin,
  108. isLoading: isLoading ?? this.isLoading,
  109. isSubmitting: isSubmitting ?? this.isSubmitting,
  110. isLoadingCoins: isLoadingCoins ?? this.isLoadingCoins,
  111. errorMessage: errorMessage,
  112. );
  113. }
  114. }
  115. Decimal _d(String v) => Decimal.tryParse(v) ?? Decimal.zero;
  116. class TransferNotifier extends AutoDisposeNotifier<TransferState> {
  117. TransferInitOptions _options = const TransferInitOptions();
  118. bool _initialized = false;
  119. @override
  120. TransferState build() => const TransferState();
  121. Future<void> init(TransferInitOptions options) async {
  122. _options = options;
  123. final pair = options.spotTradingBridgeOnly
  124. ? normalizeTransferPair(
  125. options.from ?? kWalletSpot,
  126. options.to ?? kWalletSpotTrading,
  127. )
  128. : normalizeTransferPair(
  129. options.from ?? kWalletSpot,
  130. options.to ?? kWalletSwap,
  131. );
  132. state = state.copyWith(
  133. fromType: pair.$1,
  134. toType: pair.$2,
  135. spotTradingBridgeOnly: options.spotTradingBridgeOnly,
  136. preferDefaultSymbol: options.preferDefaultSymbol,
  137. selectedCoin: options.defaultSymbol.toUpperCase(),
  138. isLoading: true,
  139. errorMessage: null,
  140. );
  141. await _loadLegacyBalances();
  142. if (state.isSpotCoinTransfer || options.spotTradingBridgeOnly) {
  143. await _loadSpotTransferAssets();
  144. }
  145. state = state.copyWith(isLoading: false);
  146. _initialized = true;
  147. }
  148. Future<void> _loadLegacyBalances() async {
  149. try {
  150. final dio = ref.read(dioClientProvider);
  151. final assetService = AssetService(dio);
  152. final spotService = SpotService(dio);
  153. final results = await Future.wait([
  154. assetService.getTodayPnl(),
  155. spotService.getAssets().catchError((_) => <String, dynamic>{}),
  156. ]);
  157. final todayPnl = results[0] as TodayPnl;
  158. final spotData = results[1] as Map<String, dynamic>;
  159. state = state.copyWith(
  160. legacyBalances: _legacyBalancesFromTodayPnl(todayPnl),
  161. spotTradingUsdtBalance:
  162. parseSpotAvailableMap(spotData)['USDT'] ?? '0',
  163. );
  164. } catch (e) {
  165. state = state.copyWith(errorMessage: e.toString());
  166. }
  167. }
  168. Map<String, String> _legacyBalancesFromTodayPnl(TodayPnl todayPnl) {
  169. final list = todayPnl.accountInfoList;
  170. String balAt(int idx) {
  171. if (idx < 0 || idx >= list.length) {
  172. return '0';
  173. }
  174. return list[idx].balance.toString();
  175. }
  176. return {
  177. kWalletSwap: balAt(kSwapAccountIdx),
  178. kWalletFollow: balAt(kFollowAccountIdx),
  179. kWalletSpot: balAt(kSpotAccountIdx),
  180. };
  181. }
  182. Future<void> _loadSpotTransferAssets() async {
  183. state = state.copyWith(isLoadingCoins: true);
  184. try {
  185. final dio = ref.read(dioClientProvider);
  186. final spotService = SpotService(dio);
  187. final results = await Future.wait([
  188. spotService.getCoins().catchError((_) => <Map<String, dynamic>>[]),
  189. spotService.getAssets().catchError((_) => <String, dynamic>{}),
  190. dio
  191. .post<Map<String, dynamic>>('uc/asset/wallet', data: {})
  192. .then((r) => r.data)
  193. .catchError((_) => null),
  194. ]);
  195. final coinsRaw = results[0] as List<Map<String, dynamic>>;
  196. final spotData = results[1] as Map<String, dynamic>;
  197. final fundRaw = results[2];
  198. var openCoins = parseOpenCoinSymbols(coinsRaw);
  199. var selected = state.selectedCoin.toUpperCase();
  200. if (_options.preferDefaultSymbol &&
  201. openCoins.contains(_options.defaultSymbol.toUpperCase())) {
  202. selected = _options.defaultSymbol.toUpperCase();
  203. } else if (!openCoins.contains(selected)) {
  204. selected = openCoins.contains('USDT') ? 'USDT' : openCoins.first;
  205. }
  206. state = state.copyWith(
  207. openCoins: openCoins,
  208. selectedCoin: selected,
  209. spotAvailable: parseSpotAvailableMap(spotData),
  210. fundAvailable: parseFundWalletBalances(fundRaw),
  211. spotTradingUsdtBalance:
  212. parseSpotAvailableMap(spotData)['USDT'] ?? state.spotTradingUsdtBalance,
  213. isLoadingCoins: false,
  214. );
  215. } catch (e) {
  216. state = state.copyWith(isLoadingCoins: false, errorMessage: e.toString());
  217. }
  218. }
  219. Future<void> refresh() async {
  220. if (!_initialized) {
  221. return;
  222. }
  223. await _loadLegacyBalances();
  224. if (state.isSpotCoinTransfer || state.spotTradingBridgeOnly) {
  225. await _loadSpotTransferAssets();
  226. }
  227. }
  228. void selectCoin(String coin) {
  229. state = state.copyWith(selectedCoin: coin.toUpperCase());
  230. }
  231. void setFromType(String type) {
  232. final pair = normalizeTransferPair(type, state.toType);
  233. state = state.copyWith(fromType: pair.$1, toType: pair.$2);
  234. _maybeLoadSpotAssets();
  235. }
  236. void setToType(String type) {
  237. final pair = normalizeTransferPair(state.fromType, type);
  238. state = state.copyWith(fromType: pair.$1, toType: pair.$2);
  239. _maybeLoadSpotAssets();
  240. }
  241. void _maybeLoadSpotAssets() {
  242. if (state.isSpotCoinTransfer) {
  243. Future.microtask(_loadSpotTransferAssets);
  244. }
  245. }
  246. void swapAccounts() {
  247. if (state.spotTradingBridgeOnly) {
  248. final nextFrom =
  249. state.fromType == kWalletSpot ? kWalletSpotTrading : kWalletSpot;
  250. final nextTo =
  251. nextFrom == kWalletSpot ? kWalletSpotTrading : kWalletSpot;
  252. state = state.copyWith(fromType: nextFrom, toType: nextTo);
  253. Future.microtask(_loadSpotTransferAssets);
  254. return;
  255. }
  256. final pair = normalizeTransferPair(state.toType, state.fromType);
  257. state = state.copyWith(fromType: pair.$1, toType: pair.$2);
  258. _maybeLoadSpotAssets();
  259. }
  260. Future<bool> submit(String amount) async {
  261. final pair = normalizeTransferPair(state.fromType, state.toType);
  262. state = state.copyWith(fromType: pair.$1, toType: pair.$2);
  263. if (pair.$1 == pair.$2) {
  264. state = state.copyWith(errorMessage: 'errSameAccount');
  265. return false;
  266. }
  267. final input = _d(amount);
  268. if (input <= Decimal.zero) {
  269. state = state.copyWith(errorMessage: 'errEnterAmount');
  270. return false;
  271. }
  272. if (input > state.fromBalance) {
  273. state = state.copyWith(errorMessage: 'errExceedBalance');
  274. return false;
  275. }
  276. state = state.copyWith(isSubmitting: true, errorMessage: null);
  277. try {
  278. final dio = ref.read(dioClientProvider);
  279. final fromWallet = state.fromType;
  280. final toWallet = state.toType;
  281. if (state.spotTradingBridgeOnly ||
  282. fromWallet == kWalletSpotTrading ||
  283. toWallet == kWalletSpotTrading) {
  284. final direction = fromWallet == kWalletSpot ? 1 : 2;
  285. await SpotService(dio).transfer(
  286. symbol: state.selectedCoin,
  287. amount: input.toDouble(),
  288. direction: direction,
  289. );
  290. } else {
  291. await AssetService(dio).transfer(
  292. amount: amount,
  293. from: fromWallet,
  294. to: toWallet,
  295. );
  296. }
  297. state = state.copyWith(isSubmitting: false);
  298. await refresh();
  299. return true;
  300. } catch (e) {
  301. state = state.copyWith(
  302. isSubmitting: false,
  303. errorMessage: extractErrorMessage(e),
  304. );
  305. return false;
  306. }
  307. }
  308. }
  309. final transferProvider =
  310. AutoDisposeNotifierProvider<TransferNotifier, TransferState>(
  311. TransferNotifier.new,
  312. );
  313. class TransferHistoryState {
  314. final List<TransferRecord> records;
  315. final bool isLoading;
  316. final bool hasMore;
  317. final int currentPage;
  318. final String? errorMessage;
  319. const TransferHistoryState({
  320. this.records = const [],
  321. this.isLoading = false,
  322. this.hasMore = true,
  323. this.currentPage = 1,
  324. this.errorMessage,
  325. });
  326. TransferHistoryState copyWith({
  327. List<TransferRecord>? records,
  328. bool? isLoading,
  329. bool? hasMore,
  330. int? currentPage,
  331. String? errorMessage,
  332. }) =>
  333. TransferHistoryState(
  334. records: records ?? this.records,
  335. isLoading: isLoading ?? this.isLoading,
  336. hasMore: hasMore ?? this.hasMore,
  337. currentPage: currentPage ?? this.currentPage,
  338. errorMessage: errorMessage,
  339. );
  340. }
  341. class TransferHistoryNotifier
  342. extends AutoDisposeNotifier<TransferHistoryState> {
  343. static const _pageSize = 10;
  344. @override
  345. TransferHistoryState build() {
  346. Future.microtask(_loadFirst);
  347. return const TransferHistoryState(isLoading: true);
  348. }
  349. Future<void> _loadFirst() async {
  350. state = state.copyWith(isLoading: true, errorMessage: null);
  351. try {
  352. final dio = ref.read(dioClientProvider);
  353. final records = await AssetService(dio)
  354. .getTransferList(pageNo: 1, pageSize: _pageSize);
  355. state = state.copyWith(
  356. records: records,
  357. isLoading: false,
  358. currentPage: 1,
  359. hasMore: records.length >= _pageSize,
  360. );
  361. } catch (e) {
  362. state = state.copyWith(isLoading: false, errorMessage: e.toString());
  363. }
  364. }
  365. Future<void> refresh() => _loadFirst();
  366. Future<void> loadMore() async {
  367. if (state.isLoading || !state.hasMore) {
  368. return;
  369. }
  370. state = state.copyWith(isLoading: true);
  371. try {
  372. final nextPage = state.currentPage + 1;
  373. final dio = ref.read(dioClientProvider);
  374. final records = await AssetService(dio)
  375. .getTransferList(pageNo: nextPage, pageSize: _pageSize);
  376. state = state.copyWith(
  377. records: [...state.records, ...records],
  378. isLoading: false,
  379. currentPage: nextPage,
  380. hasMore: records.length >= _pageSize,
  381. );
  382. } catch (e) {
  383. state = state.copyWith(isLoading: false, errorMessage: e.toString());
  384. }
  385. }
  386. }
  387. final transferHistoryProvider =
  388. AutoDisposeNotifierProvider<TransferHistoryNotifier, TransferHistoryState>(
  389. TransferHistoryNotifier.new,
  390. );