trader_apply_screen.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../../core/config/app_config.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/dialog_utils.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../data/repositories/copy_trading_repository.dart';
  10. import '../../../providers/customer_service_provider.dart';
  11. import '../user/protocol_screen.dart';
  12. class TraderApplyScreen extends ConsumerStatefulWidget {
  13. const TraderApplyScreen({super.key});
  14. @override
  15. ConsumerState<TraderApplyScreen> createState() => _TraderApplyScreenState();
  16. }
  17. class _TraderApplyScreenState extends ConsumerState<TraderApplyScreen> {
  18. bool _agreed = false; // 协议默认不勾选
  19. bool _loading = true;
  20. bool _submitting = false;
  21. // 合约账户资金是否满足条件(≥ 1000 USDT)
  22. bool _fundsMet = false;
  23. // 当前没有跟随交易员
  24. bool _noFollowing = true;
  25. // 已提交申请,审核中(traderLevel == "15")
  26. bool _isApplying = false;
  27. @override
  28. void initState() {
  29. super.initState();
  30. _loadConditions();
  31. }
  32. Future<void> _loadConditions() async {
  33. setState(() => _loading = true);
  34. try {
  35. final repo = ref.read(copyTradingRepositoryProvider);
  36. // 与 Android 保持一致:并行请求三个接口
  37. final walletFuture = repo.getContractWallet();
  38. final followerFuture = repo.getFollowerInfo();
  39. final followingFuture = repo.getFollowingTraders(pageSize: 10);
  40. final walletData = await walletFuture;
  41. final followerData = await followerFuture;
  42. final followingList = await followingFuture;
  43. // 合约账户权益(字段优先级:currentCapital > balance > availableBalance)
  44. double balance = 0;
  45. if (walletData != null) {
  46. final raw = walletData['currentCapital']
  47. ?? walletData['balance']
  48. ?? walletData['availableBalance']
  49. ?? 0;
  50. balance = double.tryParse(raw.toString()) ?? 0;
  51. }
  52. // 没有跟随交易员:通过实际列表判断(空列表 = 没有跟随),与 Android 逻辑一致
  53. final noFollowing = followingList.isEmpty;
  54. // 审核中:traderLevel(API 字段 "trader")== "15"
  55. final traderLevel = followerData?['trader']?.toString()
  56. ?? followerData?['traderLevel']?.toString()
  57. ?? '';
  58. final isApplying = traderLevel == '15';
  59. if (mounted) {
  60. setState(() {
  61. _fundsMet = balance >= 1000;
  62. _noFollowing = noFollowing;
  63. _isApplying = isApplying;
  64. _loading = false;
  65. });
  66. }
  67. } catch (_) {
  68. if (mounted) setState(() => _loading = false);
  69. }
  70. }
  71. void _openAgreement() {
  72. context.push('/protocol',
  73. extra: ProtocolArgs(
  74. title: AppLocalizations.of(context)!.traderAgreement,
  75. categoryCode: 'FOLLOW_PROTOCOL',
  76. ));
  77. }
  78. Future<void> _submitApply() async {
  79. if (_submitting) return; // 防抖:避免重复提交
  80. setState(() => _submitting = true);
  81. try {
  82. final repo = ref.read(copyTradingRepositoryProvider);
  83. await repo.applyTrader();
  84. if (!mounted) return;
  85. setState(() => _isApplying = true);
  86. showTopToast(
  87. context,
  88. message: AppLocalizations.of(context)!.applicationSubmitted,
  89. backgroundColor: const Color(0xFF2ECC71),
  90. );
  91. } catch (e) {
  92. if (mounted) {
  93. showTopToast(context, message: extractErrorMessage(e));
  94. }
  95. } finally {
  96. if (mounted) setState(() => _submitting = false);
  97. }
  98. }
  99. @override
  100. Widget build(BuildContext context) {
  101. final cs = Theme.of(context).colorScheme;
  102. final canSubmit = _agreed && _fundsMet && _noFollowing && !_loading && !_submitting && !_isApplying;
  103. return Scaffold(
  104. appBar: AppBar(
  105. leading: IconButton(
  106. icon: const Icon(Icons.arrow_back_ios, size: 18),
  107. onPressed: () => context.pop(),
  108. ),
  109. title: Text(AppLocalizations.of(context)!.traderApply, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
  110. ),
  111. body: Padding(
  112. padding: const EdgeInsets.symmetric(horizontal: 24),
  113. child: Column(
  114. children: [
  115. const SizedBox(height: 32),
  116. // 插图占位
  117. _ApplyIllustration(),
  118. const SizedBox(height: 28),
  119. Text(
  120. AppLocalizations.of(context)!.traderApplyConditions,
  121. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600),
  122. ),
  123. const SizedBox(height: 20),
  124. // 条件1:资金不足时显示"去划转"
  125. Builder(builder: (context) {
  126. final l10n = AppLocalizations.of(context)!;
  127. return _ConditionItem(
  128. met: _loading ? null : _fundsMet,
  129. label: l10n.contractAccountFundsReq,
  130. action: _loading || _fundsMet
  131. ? null
  132. : GestureDetector(
  133. onTap: () async {
  134. await context.push('/asset/transfer?from=SPOT&to=SWAP');
  135. if (mounted) _loadConditions();
  136. },
  137. child: Text(l10n.goTransfer, style: const TextStyle(color: AppColors.brand, fontSize: 13, fontWeight: FontWeight.w600)),
  138. ),
  139. );
  140. }),
  141. Padding(
  142. padding: const EdgeInsets.symmetric(vertical: 10),
  143. child: Container(
  144. height: 0.8,
  145. color: const Color(0xFFD0D0D0),
  146. ),
  147. ),
  148. // 条件2
  149. _ConditionItem(
  150. met: _loading ? null : _noFollowing,
  151. label: AppLocalizations.of(context)!.noFollowingTrader,
  152. ),
  153. const SizedBox(height: 40),
  154. // 协议勾选
  155. Row(
  156. children: [
  157. GestureDetector(
  158. onTap: () => setState(() => _agreed = !_agreed),
  159. child: Container(
  160. width: 18,
  161. height: 18,
  162. decoration: BoxDecoration(
  163. color: _agreed ? AppColors.brand : Colors.transparent,
  164. border: Border.all(color: _agreed ? AppColors.brand : cs.outline),
  165. borderRadius: BorderRadius.circular(3),
  166. ),
  167. child: _agreed
  168. ? Icon(Icons.check, size: 13, color: Colors.black)
  169. : null,
  170. ),
  171. ),
  172. const SizedBox(width: 8),
  173. Text(AppLocalizations.of(context)!.agreeToAgreement, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  174. GestureDetector(
  175. onTap: _openAgreement,
  176. child: Text(AppLocalizations.of(context)!.traderAgreementLink, style: const TextStyle(color: AppColors.brand, fontSize: 13)),
  177. ),
  178. ],
  179. ),
  180. const SizedBox(height: 16),
  181. // 提交按钮
  182. SizedBox(
  183. width: double.infinity,
  184. height: 50,
  185. child: ElevatedButton(
  186. onPressed: canSubmit ? _submitApply : null,
  187. style: ElevatedButton.styleFrom(
  188. backgroundColor: AppColors.brand,
  189. foregroundColor: Colors.black,
  190. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  191. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
  192. elevation: 0,
  193. ),
  194. child: _submitting
  195. ? SizedBox(
  196. width: 20,
  197. height: 20,
  198. child: CircularProgressIndicator(color: Colors.black, strokeWidth: 2),
  199. )
  200. : Builder(builder: (context) {
  201. final l10n = AppLocalizations.of(context)!;
  202. return Text(
  203. _isApplying ? l10n.reviewingApplication : l10n.submitApplication,
  204. style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
  205. );
  206. }),
  207. ),
  208. ),
  209. const SizedBox(height: 12),
  210. if (AppConfig.customerServiceEnabled)
  211. GestureDetector(
  212. onTap: () => openCustomerService(context, ref),
  213. child: Text(
  214. AppLocalizations.of(context)!.contactSupport,
  215. style: TextStyle(
  216. color: cs.onSurface.withAlpha(153),
  217. fontSize: 13,
  218. decoration: TextDecoration.underline,
  219. decorationColor: cs.onSurface.withAlpha(153),
  220. ),
  221. ),
  222. ),
  223. const SizedBox(height: 32),
  224. ],
  225. ),
  226. ),
  227. );
  228. }
  229. }
  230. // ── 插图 ──────────────────────────────────────────────────
  231. class _ApplyIllustration extends StatelessWidget {
  232. @override
  233. Widget build(BuildContext context) {
  234. return SizedBox(
  235. width: double.infinity,
  236. height: 160,
  237. child: Stack(
  238. clipBehavior: Clip.none,
  239. children: [
  240. // 左侧建筑
  241. Positioned(
  242. left: 30,
  243. bottom: 0,
  244. child: _Building(width: 72, height: 110, color: const Color(0xFFCDD2E4)),
  245. ),
  246. // 右侧建筑
  247. Positioned(
  248. right: 30,
  249. bottom: 0,
  250. child: _Building(width: 72, height: 110, color: const Color(0xFFCDD2E4)),
  251. ),
  252. // 人物(居中,比建筑高一些)
  253. Positioned(
  254. bottom: 0,
  255. left: 0,
  256. right: 0,
  257. child: Center(child: _PersonFigure()),
  258. ),
  259. // 云朵上传图标(右上方)
  260. Positioned(
  261. top: 0,
  262. right: 50,
  263. child: Container(
  264. width: 44,
  265. height: 44,
  266. decoration: BoxDecoration(
  267. color: Colors.white,
  268. shape: BoxShape.circle,
  269. boxShadow: [
  270. BoxShadow(color: Colors.black.withAlpha(20), blurRadius: 10, offset: const Offset(0, 2)),
  271. ],
  272. ),
  273. child: const Icon(Icons.cloud_upload_outlined, color: Color(0xFF5B7BE8), size: 24),
  274. ),
  275. ),
  276. // 金色星星点缀
  277. Positioned(
  278. top: 28,
  279. left: 50,
  280. child: Icon(Icons.star_rate_rounded, color: const Color(0xFFF5C842), size: 10),
  281. ),
  282. Positioned(
  283. top: 14,
  284. left: 36,
  285. child: Icon(Icons.star_rate_rounded, color: const Color(0xFFF5C842), size: 7),
  286. ),
  287. ],
  288. ),
  289. );
  290. }
  291. }
  292. class _PersonFigure extends StatelessWidget {
  293. @override
  294. Widget build(BuildContext context) {
  295. return SizedBox(
  296. width: 64,
  297. height: 130,
  298. child: Stack(
  299. alignment: Alignment.topCenter,
  300. children: [
  301. // 身体(蓝色,占下部)
  302. Positioned(
  303. bottom: 0,
  304. child: Container(
  305. width: 60,
  306. height: 98,
  307. decoration: const BoxDecoration(
  308. color: Color(0xFF4F6EE6),
  309. borderRadius: BorderRadius.only(
  310. topLeft: Radius.circular(30),
  311. topRight: Radius.circular(30),
  312. ),
  313. ),
  314. child: Column(
  315. children: [
  316. const SizedBox(height: 14),
  317. // 领带/衬衣细节
  318. Container(
  319. width: 6,
  320. height: 28,
  321. decoration: BoxDecoration(
  322. color: const Color(0xFF3A5BC7),
  323. borderRadius: BorderRadius.circular(3),
  324. ),
  325. ),
  326. ],
  327. ),
  328. ),
  329. ),
  330. // 头部(黄色圆形,在身体上方)
  331. Positioned(
  332. top: 0,
  333. child: Container(
  334. width: 38,
  335. height: 38,
  336. decoration: const BoxDecoration(
  337. color: Color(0xFFF5C842),
  338. shape: BoxShape.circle,
  339. ),
  340. ),
  341. ),
  342. // 帽子/头发(深棕色半圆在头顶)
  343. Positioned(
  344. top: 0,
  345. child: Container(
  346. width: 38,
  347. height: 18,
  348. decoration: const BoxDecoration(
  349. color: Color(0xFF6B4226),
  350. borderRadius: BorderRadius.only(
  351. topLeft: Radius.circular(19),
  352. topRight: Radius.circular(19),
  353. ),
  354. ),
  355. ),
  356. ),
  357. ],
  358. ),
  359. );
  360. }
  361. }
  362. class _Building extends StatelessWidget {
  363. const _Building({required this.width, required this.height, required this.color});
  364. final double width;
  365. final double height;
  366. final Color color;
  367. @override
  368. Widget build(BuildContext context) {
  369. const windowColor = Color(0xFFEEF1FA);
  370. const cols = 3;
  371. const rows = 4;
  372. const gap = 5.0;
  373. const winSize = 12.0;
  374. return Container(
  375. width: width,
  376. height: height,
  377. decoration: BoxDecoration(
  378. color: color,
  379. borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)),
  380. ),
  381. child: Padding(
  382. padding: const EdgeInsets.fromLTRB(7, 10, 7, 6),
  383. child: Column(
  384. mainAxisAlignment: MainAxisAlignment.start,
  385. children: List.generate(rows, (r) => Padding(
  386. padding: EdgeInsets.only(bottom: r < rows - 1 ? gap : 0),
  387. child: Row(
  388. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  389. children: List.generate(cols, (_) => Container(
  390. width: winSize,
  391. height: winSize,
  392. decoration: BoxDecoration(
  393. color: windowColor,
  394. borderRadius: BorderRadius.circular(2),
  395. ),
  396. )),
  397. ),
  398. )),
  399. ),
  400. ),
  401. );
  402. }
  403. }
  404. class _Star extends StatelessWidget {
  405. const _Star({required this.color, required this.size});
  406. final Color color;
  407. final double size;
  408. @override
  409. Widget build(BuildContext context) {
  410. return Icon(Icons.star, color: color, size: size);
  411. }
  412. }
  413. // ── 条件行 ────────────────────────────────────────────────
  414. class _ConditionItem extends StatelessWidget {
  415. const _ConditionItem({required this.met, required this.label, this.action});
  416. final bool? met;
  417. final String label;
  418. final Widget? action;
  419. @override
  420. Widget build(BuildContext context) {
  421. final cs = Theme.of(context).colorScheme;
  422. return Row(
  423. children: [
  424. if (met == null)
  425. const SizedBox(
  426. width: 22,
  427. height: 22,
  428. child: CircularProgressIndicator(strokeWidth: 2),
  429. )
  430. else
  431. Container(
  432. width: 22,
  433. height: 22,
  434. decoration: BoxDecoration(
  435. color: met! ? const Color(0xFF2ECC71) : const Color(0xFFE74C3C),
  436. shape: BoxShape.circle,
  437. ),
  438. child: Icon(
  439. met! ? Icons.check : Icons.close,
  440. color: Colors.white,
  441. size: 14,
  442. ),
  443. ),
  444. const SizedBox(width: 10),
  445. Expanded(
  446. child: Text(label, style: TextStyle(color: cs.onSurface, fontSize: 14)),
  447. ),
  448. if (action != null) action!,
  449. ],
  450. );
  451. }
  452. }