copy_trading_provider.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import 'dart:async';
  2. import 'package:flutter/widgets.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../data/models/copy_trading/trader.dart';
  5. import '../data/repositories/copy_trading_repository.dart';
  6. import 'auth_provider.dart';
  7. // ── 排序枚举 ─────────────────────────────────────────────
  8. enum TraderSort {
  9. comprehensive, // 综合排序 → API orderBy=10
  10. winRate30d, // 近14天胜率 → orderBy=30
  11. roi30d, // 近14天收益率 → orderBy=20
  12. }
  13. extension TraderSortExt on TraderSort {
  14. String get apiOrderBy {
  15. switch (this) {
  16. case TraderSort.comprehensive: return '10';
  17. case TraderSort.winRate30d: return '30';
  18. case TraderSort.roi30d: return '20';
  19. }
  20. }
  21. }
  22. // ── UI State ─────────────────────────────────────────────
  23. class CopyTradingState {
  24. final List<Trader> traders;
  25. final bool isLoading;
  26. final String? error;
  27. final String searchKeyword;
  28. /// 0=普通带单 1=无损带单 2=未登录时为「全部」;已登录时为「我的收藏」(与 Web 一致)
  29. final int tabIndex;
  30. final TraderSort sort;
  31. /// 是否是带单员(traderLevel == "20")
  32. final bool isTrader;
  33. /// 权益数据:
  34. /// - 跟单员:来自 swap/follow-wallet/get(balance 字段)
  35. /// - 带单员:来自 swap/wallet-new/get(currentCapital 字段)
  36. final Map<String, dynamic>? wallet;
  37. /// 带单员专属统计(follow/customer/trader-info/{id})
  38. /// 字段:currentClearedProfit、totalFollowProfit、following、maxFollow
  39. final Map<String, dynamic>? traderInfo;
  40. // ── 分页 ──────────────────────────────────────────────
  41. final int page;
  42. final bool hasMore;
  43. final bool isLoadingMore;
  44. const CopyTradingState({
  45. this.traders = const [],
  46. this.isLoading = false,
  47. this.error,
  48. this.searchKeyword = '',
  49. this.tabIndex = 0,
  50. this.sort = TraderSort.comprehensive,
  51. this.isTrader = false,
  52. this.wallet,
  53. this.traderInfo,
  54. this.page = 1,
  55. this.hasMore = true,
  56. this.isLoadingMore = false,
  57. });
  58. CopyTradingState copyWith({
  59. List<Trader>? traders,
  60. bool? isLoading,
  61. String? error,
  62. String? searchKeyword,
  63. int? tabIndex,
  64. TraderSort? sort,
  65. bool? isTrader,
  66. Map<String, dynamic>? wallet,
  67. Map<String, dynamic>? traderInfo,
  68. int? page,
  69. bool? hasMore,
  70. bool? isLoadingMore,
  71. }) =>
  72. CopyTradingState(
  73. traders: traders ?? this.traders,
  74. isLoading: isLoading ?? this.isLoading,
  75. error: error,
  76. searchKeyword: searchKeyword ?? this.searchKeyword,
  77. tabIndex: tabIndex ?? this.tabIndex,
  78. sort: sort ?? this.sort,
  79. isTrader: isTrader ?? this.isTrader,
  80. wallet: wallet ?? this.wallet,
  81. traderInfo: traderInfo ?? this.traderInfo,
  82. page: page ?? this.page,
  83. hasMore: hasMore ?? this.hasMore,
  84. isLoadingMore: isLoadingMore ?? this.isLoadingMore,
  85. );
  86. String get _traderTypeForList {
  87. if (tabIndex == 2) {
  88. return ''; // 未登录第三 tab「全部」;已登录不会在公开列表链路使用
  89. }
  90. switch (tabIndex) {
  91. case 0:
  92. return '0';
  93. case 1:
  94. return '1';
  95. default:
  96. return '';
  97. }
  98. }
  99. List<Trader> get displayTraders {
  100. if (searchKeyword.isEmpty) return traders;
  101. final kw = searchKeyword.toLowerCase();
  102. return traders.where((t) => t.name.toLowerCase().contains(kw)).toList();
  103. }
  104. }
  105. // ── Notifier ─────────────────────────────────────────────
  106. class CopyTradingNotifier extends Notifier<CopyTradingState> {
  107. static const _pageSize = 10;
  108. Timer? _searchDebounce;
  109. @override
  110. CopyTradingState build() {
  111. // 用 listen 代替 watch,避免登录/登出时 notifier 整体重建与同帧
  112. // context.go('/') 导航并发,触发 element._lifecycleState 断言崩溃。
  113. // (同 app_router.dart 中登录时不调用 notifyListeners 的原因一致)
  114. ref.listen<bool>(isLoggedInProvider, (prev, next) {
  115. if (prev == next) return;
  116. // 推迟到下一帧:确保 GoRouter 导航操作完成后再重置 state,
  117. // 防止两者同帧操作 element tree 产生冲突。
  118. WidgetsBinding.instance.addPostFrameCallback((_) {
  119. state = CopyTradingState(isLoading: true);
  120. if (next) {
  121. _load();
  122. } else {
  123. _loadPublic();
  124. }
  125. });
  126. });
  127. final isLoggedIn = ref.read(isLoggedInProvider);
  128. if (isLoggedIn) {
  129. Future.microtask(_load);
  130. } else {
  131. Future.microtask(_loadPublic);
  132. }
  133. return CopyTradingState(isLoading: true);
  134. }
  135. CopyTradingRepository get _repo => ref.read(copyTradingRepositoryProvider);
  136. bool _favoriteTabLoggedIn() =>
  137. state.tabIndex == 2 && ref.read(isLoggedInProvider);
  138. Future<List<Trader>> _mergeFavoriteFlags(List<Trader> traders) async {
  139. if (traders.isEmpty) {
  140. return traders;
  141. }
  142. try {
  143. final rows = await _repo.getFavoriteList(currentPage: 1, pageSize: 200);
  144. final favIds = <String>{};
  145. for (final r in rows) {
  146. final sid =
  147. '${r['id'] ?? r['traderId'] ?? r['trader_id'] ?? ''}'.trim();
  148. if (sid.isNotEmpty) {
  149. favIds.add(sid);
  150. }
  151. }
  152. return traders
  153. .map((t) => t.copyWith(isFavorited: favIds.contains(t.id)))
  154. .toList();
  155. } catch (_) {
  156. return traders;
  157. }
  158. }
  159. Future<void> _load({bool silent = false}) async {
  160. if (!silent) {
  161. state = state.copyWith(isLoading: true, error: null);
  162. } else {
  163. state = state.copyWith(error: null);
  164. }
  165. try {
  166. if (_favoriteTabLoggedIn()) {
  167. final favoriteResults = await Future.wait([
  168. _repo.getFollowerInfo(),
  169. _repo.getFavoriteList(currentPage: 1, pageSize: 200),
  170. _repo.getFollowWallet(),
  171. _repo.getContractWallet(),
  172. ]);
  173. final followerInfo = favoriteResults[0] as Map<String, dynamic>?;
  174. final rawList = favoriteResults[1] as List<Map<String, dynamic>>;
  175. final followWallet = favoriteResults[2] as Map<String, dynamic>?;
  176. final contractWallet = favoriteResults[3] as Map<String, dynamic>?;
  177. final traderLevel = followerInfo?['trader']?.toString() ??
  178. followerInfo?['traderLevel']?.toString() ??
  179. '';
  180. final isTrader = traderLevel == '20';
  181. final traderId = followerInfo?['id']?.toString() ?? '';
  182. Map<String, dynamic>? traderInfoData;
  183. if (isTrader && traderId.isNotEmpty) {
  184. traderInfoData = await _repo.getTraderInfo(traderId);
  185. }
  186. final wallet = isTrader ? contractWallet : followWallet;
  187. final traders = rawList
  188. .map((e) => Trader.fromApi(e, isFavoritedOverride: true))
  189. .toList();
  190. state = state.copyWith(
  191. isLoading: false,
  192. error: null,
  193. traders: traders,
  194. isTrader: isTrader,
  195. wallet: wallet,
  196. traderInfo: traderInfoData,
  197. page: 1,
  198. hasMore: false,
  199. isLoadingMore: false,
  200. );
  201. return;
  202. }
  203. final results = await Future.wait([
  204. _repo.getFollowerInfo(),
  205. _repo.getTraderList(
  206. orderBy: state.sort.apiOrderBy,
  207. traderType: state._traderTypeForList,
  208. nickName: state.searchKeyword,
  209. page: 1,
  210. pageSize: _pageSize,
  211. ),
  212. _repo.getFollowWallet(),
  213. _repo.getContractWallet(),
  214. ]);
  215. final followerInfo = results[0] as Map<String, dynamic>?;
  216. final rawList = results[1] as List<Map<String, dynamic>>;
  217. final followWallet = results[2] as Map<String, dynamic>?;
  218. final contractWallet = results[3] as Map<String, dynamic>?;
  219. final traderLevel = followerInfo?['trader']?.toString() ??
  220. followerInfo?['traderLevel']?.toString() ??
  221. '';
  222. final isTrader = traderLevel == '20';
  223. final traderId = followerInfo?['id']?.toString() ?? '';
  224. Map<String, dynamic>? traderInfoData;
  225. if (isTrader && traderId.isNotEmpty) {
  226. traderInfoData = await _repo.getTraderInfo(traderId);
  227. }
  228. final wallet = isTrader ? contractWallet : followWallet;
  229. final isNoLossTab = state.tabIndex == 1;
  230. var traders = rawList
  231. .map((e) => Trader.fromApi(e, isNoLoss: isNoLossTab))
  232. .toList();
  233. traders = await _mergeFavoriteFlags(traders);
  234. state = state.copyWith(
  235. isLoading: false,
  236. traders: traders,
  237. isTrader: isTrader,
  238. wallet: wallet,
  239. traderInfo: traderInfoData,
  240. page: 1,
  241. hasMore: rawList.length >= _pageSize,
  242. isLoadingMore: false,
  243. );
  244. } catch (e) {
  245. state = state.copyWith(
  246. isLoading: false,
  247. error: e.toString(),
  248. traders: [],
  249. page: 1,
  250. hasMore: false,
  251. );
  252. }
  253. }
  254. Future<void> _loadPublic() async {
  255. state = state.copyWith(isLoading: true, error: null);
  256. try {
  257. final rawList = await _repo.getPublicTraderList(
  258. page: 1,
  259. pageSize: _pageSize,
  260. traderType: state._traderTypeForList,
  261. orderBy: state.sort.apiOrderBy,
  262. );
  263. final isNoLossTab = state.tabIndex == 1;
  264. final traders = rawList.map((e) => Trader.fromApi(e, isNoLoss: isNoLossTab)).toList();
  265. state = state.copyWith(
  266. isLoading: false,
  267. traders: traders,
  268. page: 1,
  269. hasMore: rawList.length >= _pageSize,
  270. isLoadingMore: false,
  271. );
  272. } catch (_) {
  273. state = state.copyWith(isLoading: false, traders: [], page: 1, hasMore: false);
  274. }
  275. }
  276. Future<void> loadMore() async {
  277. if (_favoriteTabLoggedIn()) {
  278. return;
  279. }
  280. if (!state.hasMore || state.isLoadingMore || state.isLoading) {
  281. return;
  282. }
  283. final nextPage = state.page + 1;
  284. state = state.copyWith(isLoadingMore: true);
  285. try {
  286. final isLoggedIn = ref.read(isLoggedInProvider);
  287. List<Map<String, dynamic>> rawList;
  288. if (isLoggedIn) {
  289. rawList = await _repo.getTraderList(
  290. orderBy: state.sort.apiOrderBy,
  291. traderType: state._traderTypeForList,
  292. nickName: state.searchKeyword,
  293. page: nextPage,
  294. pageSize: _pageSize,
  295. );
  296. } else {
  297. rawList = await _repo.getPublicTraderList(
  298. page: nextPage,
  299. pageSize: _pageSize,
  300. traderType: state._traderTypeForList,
  301. orderBy: state.sort.apiOrderBy,
  302. );
  303. }
  304. final isNoLossTab = state.tabIndex == 1;
  305. final more = rawList
  306. .map((e) => Trader.fromApi(e, isNoLoss: isNoLossTab))
  307. .toList();
  308. final mergedMore = await _mergeFavoriteFlags(more);
  309. state = state.copyWith(
  310. isLoadingMore: false,
  311. traders: [...state.traders, ...mergedMore],
  312. page: nextPage,
  313. hasMore: rawList.length >= _pageSize,
  314. );
  315. } catch (_) {
  316. state = state.copyWith(isLoadingMore: false);
  317. }
  318. }
  319. void setTab(int index) {
  320. state = state.copyWith(tabIndex: index, traders: [], page: 1, hasMore: true, isLoadingMore: false, isLoading: true);
  321. final isLoggedIn = ref.read(isLoggedInProvider);
  322. Future.microtask(isLoggedIn ? _load : _loadPublic);
  323. }
  324. void setSearch(String kw) {
  325. state = state.copyWith(searchKeyword: kw);
  326. if (_favoriteTabLoggedIn()) {
  327. return;
  328. }
  329. _searchDebounce?.cancel();
  330. _searchDebounce = Timer(const Duration(milliseconds: 400), () {
  331. final isLoggedIn = ref.read(isLoggedInProvider);
  332. Future.microtask(isLoggedIn ? _load : _loadPublic);
  333. });
  334. }
  335. void setSort(TraderSort sort) {
  336. if (_favoriteTabLoggedIn()) {
  337. state = state.copyWith(sort: sort);
  338. return;
  339. }
  340. state = state.copyWith(sort: sort, traders: [], page: 1, hasMore: true, isLoadingMore: false);
  341. final isLoggedIn = ref.read(isLoggedInProvider);
  342. Future.microtask(isLoggedIn ? _load : _loadPublic);
  343. }
  344. Future<void> refresh() {
  345. final isLoggedIn = ref.read(isLoggedInProvider);
  346. return isLoggedIn ? _load() : _loadPublic();
  347. }
  348. Future<void> silentRefresh() {
  349. final isLoggedIn = ref.read(isLoggedInProvider);
  350. return isLoggedIn ? _load(silent: true) : _loadPublic();
  351. }
  352. /// 收藏 / 取消收藏。成功返回新的收藏状态,失败还原列表并返回 null。
  353. Future<bool?> toggleFavorite(Trader trader) async {
  354. if (!ref.read(isLoggedInProvider)) {
  355. return null;
  356. }
  357. final was = trader.isFavorited;
  358. final id = trader.id;
  359. if (id.isEmpty) {
  360. return null;
  361. }
  362. List<Trader> optimistic(List<Trader> list) {
  363. return list
  364. .map((t) => t.id == id ? t.copyWith(isFavorited: !was) : t)
  365. .toList();
  366. }
  367. final prevList = state.traders;
  368. var nextList = optimistic(prevList);
  369. if (_favoriteTabLoggedIn() && was) {
  370. nextList = nextList.where((t) => t.id != id).toList();
  371. }
  372. state = state.copyWith(traders: nextList);
  373. try {
  374. if (was) {
  375. await _repo.unfavoriteTrader(id);
  376. } else {
  377. await _repo.favoriteTrader(id);
  378. }
  379. return !was;
  380. } catch (_) {
  381. state = state.copyWith(traders: prevList);
  382. return null;
  383. }
  384. }
  385. }
  386. final copyTradingProvider =
  387. NotifierProvider<CopyTradingNotifier, CopyTradingState>(
  388. CopyTradingNotifier.new,
  389. );