withdraw_provider.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. import 'dart:async';
  2. import 'package:decimal/decimal.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../core/network/dio_client.dart';
  5. import '../core/utils/dialog_utils.dart' show extractErrorMessage;
  6. import '../data/models/asset/account_auth.dart';
  7. import '../data/models/asset/withdraw_balance.dart';
  8. import '../data/models/asset/withdraw_coin_option.dart';
  9. import '../data/models/asset/withdraw_record.dart';
  10. import '../data/services/withdraw_service.dart';
  11. // ══════════════════════════════════════════════════════════════
  12. // Withdraw State
  13. // ══════════════════════════════════════════════════════════════
  14. class WithdrawState {
  15. /// 0=链上提币, 1=内部转账
  16. final int tabIndex;
  17. final List<WithdrawNetworkItem> networkList;
  18. final int selectedNetworkId;
  19. final List<WithdrawCoinOption> onchainSubCoins;
  20. final bool onchainSubCoinsLoading;
  21. final int selectedSubCoinIndex;
  22. final List<WithdrawCoinOption> transferMainCoins;
  23. final int selectedTransferCoinIndex;
  24. final List<SupportCoinInfo> coinInfoList;
  25. final WithdrawBalance? balance;
  26. final AccountAuth? auth;
  27. final int codeCountdown;
  28. final bool isSubmitting;
  29. final bool isLoading;
  30. final String? errorMessage;
  31. const WithdrawState({
  32. this.tabIndex = 0,
  33. this.networkList = const [],
  34. this.selectedNetworkId = 0,
  35. this.onchainSubCoins = const [],
  36. this.onchainSubCoinsLoading = false,
  37. this.selectedSubCoinIndex = 0,
  38. this.transferMainCoins = const [],
  39. this.selectedTransferCoinIndex = 0,
  40. this.coinInfoList = const [],
  41. this.balance,
  42. this.auth,
  43. this.codeCountdown = 0,
  44. this.isSubmitting = false,
  45. this.isLoading = false,
  46. this.errorMessage,
  47. });
  48. WithdrawCoinOption? get onchainSelectedCoin =>
  49. onchainSubCoins.isNotEmpty && selectedSubCoinIndex < onchainSubCoins.length
  50. ? onchainSubCoins[selectedSubCoinIndex]
  51. : null;
  52. WithdrawCoinOption? get transferSelectedCoin =>
  53. transferMainCoins.isNotEmpty &&
  54. selectedTransferCoinIndex < transferMainCoins.length
  55. ? transferMainCoins[selectedTransferCoinIndex]
  56. : null;
  57. Decimal get fee {
  58. final coin = onchainSelectedCoin;
  59. if (coin == null) {
  60. return Decimal.zero;
  61. }
  62. return Decimal.tryParse(coin.withdrawFee) ?? Decimal.zero;
  63. }
  64. Decimal get minWithdrawAmount {
  65. final coin = onchainSelectedCoin;
  66. if (coin == null) {
  67. return Decimal.zero;
  68. }
  69. final min = Decimal.tryParse(coin.minWithdraw) ?? Decimal.zero;
  70. if (min > Decimal.zero) {
  71. return min;
  72. }
  73. final defaults = networkWithdrawDefaults[coin.networkLabel];
  74. if (defaults != null) {
  75. return Decimal.tryParse(defaults.min) ?? Decimal.zero;
  76. }
  77. return Decimal.zero;
  78. }
  79. Decimal get transferMinAmount {
  80. final coin = transferSelectedCoin;
  81. if (coin == null) {
  82. return Decimal.zero;
  83. }
  84. return Decimal.tryParse(coin.minTransfer) ?? Decimal.zero;
  85. }
  86. Decimal get currentMinAmount =>
  87. tabIndex == 0 ? minWithdrawAmount : transferMinAmount;
  88. Decimal get withdrawableBalance =>
  89. _balanceForCoin(onchainSelectedCoin, isTransfer: false);
  90. Decimal get transferableBalance =>
  91. _balanceForCoin(transferSelectedCoin, isTransfer: true);
  92. Decimal get availableBalance =>
  93. tabIndex == 0 ? withdrawableBalance : transferableBalance;
  94. String get onchainCoinSymbol =>
  95. symbolForWithdrawCoin(onchainSelectedCoin);
  96. String get transferCoinSymbol =>
  97. symbolForWithdrawCoin(transferSelectedCoin);
  98. String get onChainSubmitUnit =>
  99. onchainSelectedCoin?.unit.toLowerCase() ?? 'usdt';
  100. String get transferSubmitUnit =>
  101. transferSelectedCoin?.unit.toLowerCase() ?? 'usdt';
  102. bool get isGoogleBound => auth?.isGoogleVerified ?? false;
  103. Decimal _balanceForCoin(WithdrawCoinOption? coin, {required bool isTransfer}) {
  104. if (coin == null) {
  105. return Decimal.zero;
  106. }
  107. if (isTransfer && coin.unit.toUpperCase() == 'USDT') {
  108. return balance?.transferableBalance ?? balance?.balance ?? Decimal.zero;
  109. }
  110. if (!isTransfer && coin.isUsdtFamily) {
  111. return balance?.withdrawableBalance ?? balance?.balance ?? Decimal.zero;
  112. }
  113. return Decimal.tryParse(coin.balance) ?? Decimal.zero;
  114. }
  115. WithdrawState copyWith({
  116. int? tabIndex,
  117. List<WithdrawNetworkItem>? networkList,
  118. int? selectedNetworkId,
  119. List<WithdrawCoinOption>? onchainSubCoins,
  120. bool? onchainSubCoinsLoading,
  121. int? selectedSubCoinIndex,
  122. List<WithdrawCoinOption>? transferMainCoins,
  123. int? selectedTransferCoinIndex,
  124. List<SupportCoinInfo>? coinInfoList,
  125. WithdrawBalance? balance,
  126. AccountAuth? auth,
  127. int? codeCountdown,
  128. bool? isSubmitting,
  129. bool? isLoading,
  130. String? errorMessage,
  131. }) =>
  132. WithdrawState(
  133. tabIndex: tabIndex ?? this.tabIndex,
  134. networkList: networkList ?? this.networkList,
  135. selectedNetworkId: selectedNetworkId ?? this.selectedNetworkId,
  136. onchainSubCoins: onchainSubCoins ?? this.onchainSubCoins,
  137. onchainSubCoinsLoading:
  138. onchainSubCoinsLoading ?? this.onchainSubCoinsLoading,
  139. selectedSubCoinIndex: selectedSubCoinIndex ?? this.selectedSubCoinIndex,
  140. transferMainCoins: transferMainCoins ?? this.transferMainCoins,
  141. selectedTransferCoinIndex:
  142. selectedTransferCoinIndex ?? this.selectedTransferCoinIndex,
  143. coinInfoList: coinInfoList ?? this.coinInfoList,
  144. balance: balance ?? this.balance,
  145. auth: auth ?? this.auth,
  146. codeCountdown: codeCountdown ?? this.codeCountdown,
  147. isSubmitting: isSubmitting ?? this.isSubmitting,
  148. isLoading: isLoading ?? this.isLoading,
  149. errorMessage: errorMessage,
  150. );
  151. }
  152. // ══════════════════════════════════════════════════════════════
  153. // Withdraw Notifier
  154. // ══════════════════════════════════════════════════════════════
  155. class WithdrawNotifier extends Notifier<WithdrawState> {
  156. Timer? _countdownTimer;
  157. @override
  158. WithdrawState build() {
  159. ref.onDispose(() => _countdownTimer?.cancel());
  160. Future.microtask(_load);
  161. return const WithdrawState(isLoading: true);
  162. }
  163. Future<void> _load() async {
  164. state = state.copyWith(isLoading: true, errorMessage: null);
  165. try {
  166. final dio = ref.read(dioClientProvider);
  167. final withdrawService = WithdrawService(dio);
  168. final results = await Future.wait([
  169. withdrawService.getBalance('usdt'),
  170. withdrawService.getSecuritySetting(),
  171. withdrawService.getSupportCoinInfo(),
  172. withdrawService.getWithdrawNetworks(),
  173. ]);
  174. final balance = results[0] as WithdrawBalance;
  175. final auth = results[1] as AccountAuth;
  176. final coinInfoList = results[2] as List<SupportCoinInfo>;
  177. final networkList = results[3] as List<WithdrawNetworkItem>;
  178. final transferMainCoins =
  179. await withdrawService.getTransferParents(coinInfoList: coinInfoList);
  180. state = state.copyWith(
  181. balance: balance,
  182. auth: auth,
  183. coinInfoList: coinInfoList,
  184. networkList: networkList,
  185. transferMainCoins: transferMainCoins,
  186. isLoading: false,
  187. );
  188. await _resetSelection();
  189. } catch (e) {
  190. state = state.copyWith(
  191. isLoading: false,
  192. errorMessage: extractErrorMessage(e),
  193. );
  194. }
  195. }
  196. Future<void> _resetSelection() async {
  197. final nets = state.networkList;
  198. var networkId = state.selectedNetworkId;
  199. if (nets.isEmpty) {
  200. networkId = 0;
  201. state = state.copyWith(
  202. selectedNetworkId: 0,
  203. onchainSubCoins: [],
  204. selectedSubCoinIndex: 0,
  205. );
  206. } else {
  207. if (!nets.any((n) => n.id == networkId)) {
  208. networkId = nets.first.id;
  209. }
  210. state = state.copyWith(
  211. selectedNetworkId: networkId,
  212. selectedSubCoinIndex: 0,
  213. );
  214. await _loadOnchainSubCoins(networkId);
  215. }
  216. var transferIdx = state.selectedTransferCoinIndex;
  217. if (transferIdx >= state.transferMainCoins.length) {
  218. transferIdx = 0;
  219. state = state.copyWith(selectedTransferCoinIndex: transferIdx);
  220. }
  221. }
  222. Future<void> _loadOnchainSubCoins(int networkId) async {
  223. if (networkId <= 0) {
  224. state = state.copyWith(onchainSubCoins: []);
  225. return;
  226. }
  227. state = state.copyWith(onchainSubCoinsLoading: true);
  228. try {
  229. final dio = ref.read(dioClientProvider);
  230. final coins = await WithdrawService(dio).getWithdrawConfigs(
  231. networkId: networkId,
  232. coinInfoList: state.coinInfoList,
  233. );
  234. var subIdx = state.selectedSubCoinIndex;
  235. if (subIdx >= coins.length) {
  236. subIdx = 0;
  237. }
  238. state = state.copyWith(
  239. onchainSubCoins: coins,
  240. selectedSubCoinIndex: subIdx,
  241. onchainSubCoinsLoading: false,
  242. );
  243. } catch (e) {
  244. state = state.copyWith(
  245. onchainSubCoins: [],
  246. onchainSubCoinsLoading: false,
  247. errorMessage: extractErrorMessage(e),
  248. );
  249. }
  250. }
  251. Future<void> refresh() => _load();
  252. void setTab(int index) {
  253. state = state.copyWith(tabIndex: index);
  254. }
  255. Future<void> selectNetwork(int networkId) async {
  256. if (networkId <= 0 ||
  257. !state.networkList.any((n) => n.id == networkId)) {
  258. return;
  259. }
  260. state = state.copyWith(
  261. selectedNetworkId: networkId,
  262. selectedSubCoinIndex: 0,
  263. );
  264. await _loadOnchainSubCoins(networkId);
  265. }
  266. void selectSubCoin(int index) {
  267. if (index >= 0 && index < state.onchainSubCoins.length) {
  268. state = state.copyWith(selectedSubCoinIndex: index);
  269. }
  270. }
  271. void selectTransferCoin(int index) {
  272. if (index >= 0 && index < state.transferMainCoins.length) {
  273. state = state.copyWith(selectedTransferCoinIndex: index);
  274. }
  275. }
  276. Future<String?> sendEmailCode({
  277. required String address,
  278. required String amount,
  279. }) async {
  280. if (state.tabIndex == 0 && state.onchainSelectedCoin == null) {
  281. return 'errSelectNetwork';
  282. }
  283. if (state.tabIndex == 1 && state.transferSelectedCoin == null) {
  284. return 'errSelectCoin';
  285. }
  286. if (address.isEmpty) {
  287. return 'errEnterAddress';
  288. }
  289. if (amount.isEmpty) {
  290. return 'errEnterAmount';
  291. }
  292. try {
  293. final dio = ref.read(dioClientProvider);
  294. final unit = state.tabIndex == 1
  295. ? state.transferSubmitUnit
  296. : state.onChainSubmitUnit;
  297. await WithdrawService(dio).sendWithdrawEmailCode(
  298. unit: unit,
  299. address: address,
  300. amount: amount,
  301. );
  302. state = state.copyWith(codeCountdown: 60);
  303. _countdownTimer?.cancel();
  304. _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
  305. final remaining = state.codeCountdown - 1;
  306. if (remaining <= 0) {
  307. timer.cancel();
  308. state = state.copyWith(codeCountdown: 0);
  309. } else {
  310. state = state.copyWith(codeCountdown: remaining);
  311. }
  312. });
  313. return null;
  314. } catch (e) {
  315. return extractErrorMessage(e);
  316. }
  317. }
  318. Future<bool> submitOnChainWithdraw({
  319. required String address,
  320. required String amount,
  321. required String jyPassword,
  322. required String vcode,
  323. required String vcode2,
  324. }) async {
  325. state = state.copyWith(isSubmitting: true, errorMessage: null);
  326. try {
  327. final dio = ref.read(dioClientProvider);
  328. await WithdrawService(dio).withdrawApply(
  329. unit: state.onChainSubmitUnit,
  330. amount: amount,
  331. address: address,
  332. fee: state.fee.toString(),
  333. vcode: vcode,
  334. jyPassword: jyPassword,
  335. vcode2: vcode2,
  336. );
  337. state = state.copyWith(isSubmitting: false);
  338. await _load();
  339. return true;
  340. } catch (e) {
  341. state = state.copyWith(
  342. isSubmitting: false,
  343. errorMessage: extractErrorMessage(e),
  344. );
  345. return false;
  346. }
  347. }
  348. Future<bool> submitInternalTransfer({
  349. required String address,
  350. required String amount,
  351. required String jyPassword,
  352. required String vcode,
  353. required String vcode2,
  354. }) async {
  355. state = state.copyWith(isSubmitting: true, errorMessage: null);
  356. try {
  357. final dio = ref.read(dioClientProvider);
  358. await WithdrawService(dio).internalTransfer(
  359. unit: state.transferSubmitUnit,
  360. amount: amount,
  361. address: address,
  362. vcode: vcode,
  363. jyPassword: jyPassword,
  364. vcode2: vcode2,
  365. );
  366. state = state.copyWith(isSubmitting: false);
  367. await _load();
  368. return true;
  369. } catch (e) {
  370. state = state.copyWith(
  371. isSubmitting: false,
  372. errorMessage: extractErrorMessage(e),
  373. );
  374. return false;
  375. }
  376. }
  377. String? validate({
  378. required String address,
  379. required String amount,
  380. required String jyPassword,
  381. required String vcode,
  382. required String vcode2,
  383. }) {
  384. if (state.tabIndex == 0 && state.onchainSelectedCoin == null) {
  385. return 'errSelectNetwork';
  386. }
  387. if (state.tabIndex == 1 && state.transferSelectedCoin == null) {
  388. return 'errSelectCoin';
  389. }
  390. if (address.isEmpty) {
  391. return 'errEnterAddress';
  392. }
  393. if (amount.isEmpty) {
  394. return 'errEnterAmount';
  395. }
  396. if (jyPassword.isEmpty) {
  397. return 'errEnterFundPassword';
  398. }
  399. if (vcode.isEmpty) {
  400. return 'errEnterVerifyCode';
  401. }
  402. if (!state.isGoogleBound) {
  403. return 'errBindGoogleFirst';
  404. }
  405. if (vcode2.isEmpty) {
  406. return 'errEnterGoogleCode';
  407. }
  408. final amountDecimal = Decimal.tryParse(amount);
  409. if (amountDecimal == null) {
  410. return 'errAmountFormat';
  411. }
  412. if (amountDecimal < state.currentMinAmount) {
  413. final coinSymbol = state.tabIndex == 0
  414. ? state.onchainCoinSymbol
  415. : state.transferCoinSymbol;
  416. return state.tabIndex == 0
  417. ? 'errMinWithdraw:${state.currentMinAmount}:$coinSymbol'
  418. : 'errMinTransfer:${state.currentMinAmount}:$coinSymbol';
  419. }
  420. if (amountDecimal > state.availableBalance) {
  421. return 'errExceedBalance';
  422. }
  423. return null;
  424. }
  425. }
  426. final withdrawProvider = NotifierProvider<WithdrawNotifier, WithdrawState>(
  427. WithdrawNotifier.new,
  428. );
  429. // ══════════════════════════════════════════════════════════════
  430. // Withdraw History
  431. // ══════════════════════════════════════════════════════════════
  432. class WithdrawHistoryState {
  433. final List<WithdrawRecord> records;
  434. final bool isLoading;
  435. final bool hasMore;
  436. /// 链上提币当前页(后端 page 从 0 开始)
  437. final int withdrawPage;
  438. /// 内部转账当前页(后端 pageNo 从 0 开始)
  439. final int transferPage;
  440. final String? errorMessage;
  441. const WithdrawHistoryState({
  442. this.records = const [],
  443. this.isLoading = false,
  444. this.hasMore = true,
  445. this.withdrawPage = 0,
  446. this.transferPage = 0,
  447. this.errorMessage,
  448. });
  449. WithdrawHistoryState copyWith({
  450. List<WithdrawRecord>? records,
  451. bool? isLoading,
  452. bool? hasMore,
  453. int? withdrawPage,
  454. int? transferPage,
  455. String? errorMessage,
  456. }) =>
  457. WithdrawHistoryState(
  458. records: records ?? this.records,
  459. isLoading: isLoading ?? this.isLoading,
  460. hasMore: hasMore ?? this.hasMore,
  461. withdrawPage: withdrawPage ?? this.withdrawPage,
  462. transferPage: transferPage ?? this.transferPage,
  463. errorMessage: errorMessage,
  464. );
  465. }
  466. class WithdrawHistoryNotifier extends Notifier<WithdrawHistoryState> {
  467. static const _pageSize = 10;
  468. @override
  469. WithdrawHistoryState build() {
  470. Future.microtask(_loadFirst);
  471. return const WithdrawHistoryState(isLoading: true);
  472. }
  473. Future<void> _loadFirst() async {
  474. state = state.copyWith(isLoading: true, errorMessage: null);
  475. try {
  476. final service = WithdrawService(ref.read(dioClientProvider));
  477. final results = await Future.wait([
  478. service.getWithdrawRecords(page: 0, pageSize: _pageSize),
  479. service.getTransferRecords(pageNo: 0, pageSize: _pageSize),
  480. ]);
  481. final withdrawRecords = results[0] as List<WithdrawRecord>;
  482. final transferRecords = (results[1] as List<WithdrawRecord>)
  483. .map((r) => r.copyWith(isTransfer: true))
  484. .toList();
  485. final all = _merge(withdrawRecords, transferRecords);
  486. state = state.copyWith(
  487. records: all,
  488. isLoading: false,
  489. withdrawPage: 0,
  490. transferPage: 0,
  491. hasMore: withdrawRecords.length >= _pageSize ||
  492. transferRecords.length >= _pageSize,
  493. );
  494. } catch (e) {
  495. state = state.copyWith(isLoading: false, errorMessage: extractErrorMessage(e));
  496. }
  497. }
  498. Future<void> refresh() => _loadFirst();
  499. Future<void> loadMore() async {
  500. if (state.isLoading || !state.hasMore) {
  501. return;
  502. }
  503. state = state.copyWith(isLoading: true);
  504. try {
  505. final service = WithdrawService(ref.read(dioClientProvider));
  506. final nextW = state.withdrawPage + 1;
  507. final nextT = state.transferPage + 1;
  508. final results = await Future.wait([
  509. service.getWithdrawRecords(page: nextW, pageSize: _pageSize),
  510. service.getTransferRecords(pageNo: nextT, pageSize: _pageSize),
  511. ]);
  512. final withdrawRecords = results[0] as List<WithdrawRecord>;
  513. final transferRecords = (results[1] as List<WithdrawRecord>)
  514. .map((r) => r.copyWith(isTransfer: true))
  515. .toList();
  516. final appended = _merge(withdrawRecords, transferRecords);
  517. state = state.copyWith(
  518. records: [...state.records, ...appended],
  519. isLoading: false,
  520. withdrawPage: nextW,
  521. transferPage: nextT,
  522. hasMore: withdrawRecords.length >= _pageSize ||
  523. transferRecords.length >= _pageSize,
  524. );
  525. } catch (e) {
  526. state = state.copyWith(isLoading: false, errorMessage: extractErrorMessage(e));
  527. }
  528. }
  529. List<WithdrawRecord> _merge(
  530. List<WithdrawRecord> withdrawRecords,
  531. List<WithdrawRecord> transferRecords,
  532. ) {
  533. final all = [...withdrawRecords, ...transferRecords];
  534. all.sort((a, b) => b.createTime.compareTo(a.createTime));
  535. return all;
  536. }
  537. Future<bool> cancelWithdraw(String id) async {
  538. try {
  539. final dio = ref.read(dioClientProvider);
  540. await WithdrawService(dio).cancelWithdraw(id);
  541. await refresh();
  542. return true;
  543. } catch (e) {
  544. state = state.copyWith(errorMessage: extractErrorMessage(e));
  545. return false;
  546. }
  547. }
  548. }
  549. final withdrawHistoryProvider =
  550. NotifierProvider<WithdrawHistoryNotifier, WithdrawHistoryState>(
  551. WithdrawHistoryNotifier.new,
  552. );