asset_screen.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../../../core/l10n/app_localizations.dart';
  4. import '../../../core/theme/app_colors.dart';
  5. import '../../../core/utils/number_format.dart';
  6. import '../../../providers/asset_provider.dart';
  7. import '../../../providers/my_copy_trading_provider.dart';
  8. import '../../widgets/common/app_shimmer.dart';
  9. import '../../widgets/common/app_tab_bar.dart';
  10. import '../../widgets/common/network_error_body.dart';
  11. import 'asset_overview_tab.dart';
  12. import 'asset_futures_tab.dart';
  13. import 'asset_copy_trading_tab.dart';
  14. import 'asset_spot_tab.dart';
  15. import 'asset_spot_trading_tab.dart';
  16. import 'asset_staking_tab.dart';
  17. class AssetScreen extends ConsumerStatefulWidget {
  18. const AssetScreen({super.key});
  19. @override
  20. ConsumerState<AssetScreen> createState() => _AssetScreenState();
  21. }
  22. class _AssetScreenState extends ConsumerState<AssetScreen>
  23. with SingleTickerProviderStateMixin {
  24. late TabController _tabController;
  25. late PageController _pageController;
  26. List<String> _getLocalizedTabs(BuildContext context) {
  27. final l10n = AppLocalizations.of(context)!;
  28. return [
  29. l10n.assetOverview,
  30. l10n.fund,
  31. l10n.spotTab,
  32. l10n.futures,
  33. l10n.copyTrading,
  34. l10n.assetsLockedStakingAccount,
  35. ];
  36. }
  37. @override
  38. void initState() {
  39. super.initState();
  40. _tabController = TabController(length: 6, vsync: this);
  41. _pageController = PageController();
  42. // 拖动 PageView → 实时更新指示器 offset(平滑插值)
  43. _pageController.addListener(() {
  44. if (!_pageController.hasClients) return;
  45. final page = _pageController.page!;
  46. final offset = page - _tabController.index;
  47. if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
  48. _tabController.offset = offset.clamp(-1.0, 1.0);
  49. }
  50. });
  51. _tabController.addListener(_onTabChanged);
  52. }
  53. void _onTabChanged() {
  54. if (_tabController.indexIsChanging) {
  55. // Tab 点击 → 驱动 PageView 动画
  56. _pageController.animateToPage(
  57. _tabController.index,
  58. duration: const Duration(milliseconds: 280),
  59. curve: Curves.easeOut,
  60. );
  61. } else {
  62. // Tab 切换完成:更新当前子 tab 索引并按需刷新
  63. final idx = _tabController.index;
  64. final prevIdx = ref.read(currentAssetSubTabProvider);
  65. ref.read(currentAssetSubTabProvider.notifier).state = idx;
  66. if (prevIdx == 2 && idx != 2) {
  67. ref.read(assetProvider.notifier).onSpotTradingTabHidden();
  68. }
  69. WidgetsBinding.instance.addPostFrameCallback((_) {
  70. if (mounted) _refreshForTab(idx);
  71. });
  72. // 仅合约 tab(索引 3)需要定时拉持仓;现货 tab 首次进入 HTTP,后续走 WS
  73. final notifier = ref.read(assetProvider.notifier);
  74. if (idx == 3) {
  75. notifier.startPositionPolling();
  76. } else {
  77. notifier.stopPositionPolling();
  78. }
  79. }
  80. setState(() {});
  81. }
  82. void _refreshForTab(int tabIndex) {
  83. final n = ref.read(assetProvider.notifier);
  84. if (tabIndex == 2) {
  85. n.onSpotTradingTabVisible();
  86. } else {
  87. n.silentRefresh();
  88. }
  89. if (tabIndex == 4) {
  90. // 跟单 tab 额外刷新跟单仓位
  91. ref.read(myCopyTradingProvider.notifier).silentRefresh();
  92. } else if (tabIndex == 5) {
  93. n.loadStakingOrders();
  94. }
  95. }
  96. void _navigateToTab(int index) {
  97. _tabController.animateTo(index);
  98. _pageController.animateToPage(
  99. index,
  100. duration: const Duration(milliseconds: 280),
  101. curve: Curves.easeOut,
  102. );
  103. }
  104. @override
  105. void dispose() {
  106. ref.read(assetProvider.notifier).onSpotTradingTabHidden();
  107. ref.read(assetProvider.notifier).stopPositionPolling();
  108. _tabController.dispose();
  109. _pageController.dispose();
  110. super.dispose();
  111. }
  112. String _getTabLabel(int index) {
  113. const labels = [
  114. 'asset_tab_overview',
  115. 'asset_tab_funds',
  116. 'asset_tab_spot_trading',
  117. 'asset_tab_futures',
  118. 'asset_tab_copy_trading',
  119. 'asset_tab_staking',
  120. ];
  121. return labels[index];
  122. }
  123. @override
  124. Widget build(BuildContext context) {
  125. final state = ref.watch(assetProvider);
  126. final notifier = ref.read(assetProvider.notifier);
  127. return Scaffold(
  128. body: SafeArea(
  129. child: Column(
  130. children: [
  131. // ── Tab 栏 ─────────────────────────────────────
  132. TabBar(
  133. controller: _tabController,
  134. isScrollable: true,
  135. indicator: StretchTabIndicator(
  136. controller: _tabController,
  137. color: AppColors.brand,
  138. height: 3,
  139. borderRadius: 1.5,
  140. ),
  141. indicatorSize: TabBarIndicatorSize.label,
  142. labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
  143. unselectedLabelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
  144. tabAlignment: TabAlignment.start,
  145. dividerColor: Colors.transparent,
  146. labelPadding: const EdgeInsets.symmetric(horizontal: 14),
  147. padding: const EdgeInsets.fromLTRB(4, 8, 16, 0),
  148. tabs: <Widget>[
  149. for (int i = 0; i < 6; i++)
  150. Semantics(
  151. label: _getTabLabel(i),
  152. button: true,
  153. child: Tab(text: _getLocalizedTabs(context)[i]),
  154. ),
  155. ],
  156. ),
  157. // ── Tab 内容 ───────────────────────────────────
  158. Expanded(
  159. child: _buildContent(state, notifier),
  160. ),
  161. ],
  162. ),
  163. ),
  164. );
  165. }
  166. Widget _buildContent(AssetState state, AssetNotifier notifier) {
  167. if (state.isLoading) return const _AssetShimmer();
  168. if (state.errorMessage != null) {
  169. return NetworkErrorBody(onRetry: notifier.loadAssets);
  170. }
  171. return PageView(
  172. controller: _pageController,
  173. physics: const BouncingScrollPhysics(
  174. parent: AlwaysScrollableScrollPhysics(),
  175. ),
  176. onPageChanged: (index) {
  177. if (_tabController.indexIsChanging) return;
  178. _tabController.index = index;
  179. },
  180. children: [
  181. AssetOverviewTab(
  182. state: state,
  183. notifier: notifier,
  184. onNavigateToStaking: () => _navigateToTab(5),
  185. ),
  186. AssetSpotTab(state: state, notifier: notifier),
  187. AssetSpotTradingTab(state: state, notifier: notifier),
  188. AssetFuturesTab(state: state, notifier: notifier),
  189. AssetCopyTradingTab(state: state, notifier: notifier),
  190. AssetStakingTab(state: state, notifier: notifier),
  191. ],
  192. );
  193. }
  194. }
  195. // ── 资产骨架屏 ──────────────────────────────────────────────
  196. class _AssetShimmer extends StatelessWidget {
  197. const _AssetShimmer();
  198. @override
  199. Widget build(BuildContext context) {
  200. return AppShimmer(
  201. child: SingleChildScrollView(
  202. physics: const NeverScrollableScrollPhysics(),
  203. padding: const EdgeInsets.all(16),
  204. child: Column(
  205. crossAxisAlignment: CrossAxisAlignment.start,
  206. children: [
  207. // 资产估值头部
  208. shimmerBox(80, 13),
  209. const SizedBox(height: 10),
  210. shimmerBox(180, 34),
  211. const SizedBox(height: 6),
  212. shimmerBox(120, 13),
  213. const SizedBox(height: 24),
  214. // 账户卡片列表
  215. ...List.generate(3, (_) => Padding(
  216. padding: const EdgeInsets.only(bottom: 12),
  217. child: Container(
  218. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  219. decoration: BoxDecoration(
  220. color: Colors.white,
  221. borderRadius: BorderRadius.circular(12),
  222. ),
  223. child: Row(
  224. children: [
  225. shimmerCircle(22),
  226. const SizedBox(width: 12),
  227. shimmerBox(80, 14),
  228. const Spacer(),
  229. Column(
  230. crossAxisAlignment: CrossAxisAlignment.end,
  231. children: [
  232. shimmerBox(90, 15),
  233. const SizedBox(height: 4),
  234. shimmerBox(60, 12),
  235. ],
  236. ),
  237. const SizedBox(width: 8),
  238. shimmerBox(18, 18, radius: 4),
  239. ],
  240. ),
  241. ),
  242. )),
  243. const SizedBox(height: 8),
  244. // 持仓卡片
  245. Container(
  246. padding: const EdgeInsets.all(16),
  247. decoration: BoxDecoration(
  248. color: Colors.white,
  249. borderRadius: BorderRadius.circular(12),
  250. ),
  251. child: Column(
  252. children: [
  253. Row(
  254. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  255. children: [shimmerBox(100, 14), shimmerBox(60, 14)],
  256. ),
  257. const SizedBox(height: 12),
  258. Row(
  259. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  260. children: List.generate(3, (_) => shimmerBox(70, 40, radius: 6)),
  261. ),
  262. ],
  263. ),
  264. ),
  265. ],
  266. ),
  267. ),
  268. );
  269. }
  270. }
  271. // ══════════════════════════════════════════════════════════════
  272. // 共享组件(供各 Tab 文件使用)
  273. // ══════════════════════════════════════════════════════════════
  274. /// 各 Tab 通用的资产估值头部
  275. class AssetHeader extends StatelessWidget {
  276. const AssetHeader({super.key, required this.state, required this.notifier});
  277. final AssetState state;
  278. final AssetNotifier notifier;
  279. @override
  280. Widget build(BuildContext context) {
  281. final cs = Theme.of(context).colorScheme;
  282. final total = state.totalUsdtValue;
  283. final display = state.obscureBalance ? '******' : formatPrice(total, decimalPlaces: 2);
  284. return Padding(
  285. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  286. child: Column(
  287. crossAxisAlignment: CrossAxisAlignment.start,
  288. children: [
  289. Row(
  290. children: [
  291. Text(AppLocalizations.of(context)!.assetValuation, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  292. const SizedBox(width: 6),
  293. Semantics(
  294. label: 'asset_btn_toggle_balance',
  295. button: true,
  296. enabled: true,
  297. onTap: notifier.toggleObscure,
  298. child: GestureDetector(
  299. onTap: notifier.toggleObscure,
  300. child: Icon(
  301. state.obscureBalance ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  302. size: 16, color: cs.onSurface.withAlpha(153),
  303. ),
  304. ),
  305. ),
  306. ],
  307. ),
  308. const SizedBox(height: 8),
  309. Row(
  310. crossAxisAlignment: CrossAxisAlignment.end,
  311. children: [
  312. Semantics(
  313. label: 'asset_text_total_value',
  314. enabled: true,
  315. child: Text(display, style: TextStyle(color: cs.onSurface, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
  316. ),
  317. const SizedBox(width: 6),
  318. Semantics(
  319. label: 'asset_text_currency',
  320. enabled: true,
  321. child: Padding(
  322. padding: const EdgeInsets.only(bottom: 5),
  323. child: Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  324. ),
  325. ),
  326. ],
  327. ),
  328. ],
  329. ),
  330. );
  331. }
  332. }
  333. /// 合约/跟单持仓卡片
  334. class AssetPositionCard extends StatelessWidget {
  335. const AssetPositionCard({
  336. super.key,
  337. required this.obscure,
  338. this.symbol = 'BTCUSDT',
  339. this.isLong = true,
  340. this.positionType = '',
  341. this.leverage = '20X',
  342. this.pnl = '+24.35',
  343. this.pnlColor,
  344. this.roi = '+5.36%',
  345. this.rows = const [],
  346. this.onShare,
  347. });
  348. final bool obscure;
  349. final String symbol;
  350. final bool isLong;
  351. final String positionType;
  352. final String leverage;
  353. final String pnl;
  354. final Color? pnlColor;
  355. final String roi;
  356. final List<List<String>> rows;
  357. final VoidCallback? onShare;
  358. @override
  359. Widget build(BuildContext context) {
  360. final cs = Theme.of(context).colorScheme;
  361. final isDark = Theme.of(context).brightness == Brightness.dark;
  362. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  363. final effectivePnlColor = pnlColor ?? AppColors.rise;
  364. return Container(
  365. margin: const EdgeInsets.symmetric(horizontal: 16),
  366. padding: const EdgeInsets.all(16),
  367. decoration: BoxDecoration(
  368. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  369. borderRadius: BorderRadius.circular(12),
  370. border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
  371. ),
  372. child: Column(
  373. crossAxisAlignment: CrossAxisAlignment.start,
  374. children: [
  375. // 头部:币种 + 标签
  376. Row(
  377. children: [
  378. Container(
  379. width: 32, height: 32,
  380. decoration: BoxDecoration(color: sideColor, borderRadius: BorderRadius.circular(8)),
  381. child: Center(
  382. child: Text(isLong ? AppLocalizations.of(context)!.longLabel : AppLocalizations.of(context)!.shortLabel,
  383. style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700)),
  384. ),
  385. ),
  386. const SizedBox(width: 10),
  387. Text(symbol, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  388. const SizedBox(width: 8),
  389. _Tag(label: AppLocalizations.of(context)!.perpetual),
  390. const SizedBox(width: 4),
  391. _Tag(label: positionType),
  392. const SizedBox(width: 4),
  393. Container(
  394. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  395. decoration: BoxDecoration(
  396. color: cs.onSurface.withAlpha(20),
  397. borderRadius: BorderRadius.circular(4),
  398. ),
  399. child: Text(leverage, style: TextStyle(color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600)),
  400. ),
  401. const Spacer(),
  402. GestureDetector(
  403. onTap: onShare,
  404. child: Icon(Icons.share_outlined, size: 18, color: cs.onSurface.withAlpha(153)),
  405. ),
  406. ],
  407. ),
  408. const SizedBox(height: 14),
  409. // 未实现盈亏 + 收益率
  410. Container(
  411. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
  412. decoration: const BoxDecoration(),
  413. child: Row(
  414. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  415. children: [
  416. Column(
  417. crossAxisAlignment: CrossAxisAlignment.start,
  418. children: [
  419. Text('${AppLocalizations.of(context)!.unrealizedPnl} (USDT)', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  420. const SizedBox(height: 2),
  421. Text(
  422. obscure ? '******' : pnl,
  423. style: TextStyle(color: effectivePnlColor, fontSize: 20, fontWeight: FontWeight.w700),
  424. ),
  425. ],
  426. ),
  427. Column(
  428. crossAxisAlignment: CrossAxisAlignment.end,
  429. children: [
  430. Text(AppLocalizations.of(context)!.returnRate, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  431. const SizedBox(height: 2),
  432. Text(roi, style: TextStyle(color: effectivePnlColor, fontSize: 16, fontWeight: FontWeight.w600)),
  433. ],
  434. ),
  435. ],
  436. ),
  437. ),
  438. const SizedBox(height: 14),
  439. // 数据网格
  440. _DataGrid(obscure: obscure, rows: rows),
  441. ],
  442. ),
  443. );
  444. }
  445. }
  446. /// 账户行
  447. class AssetAccountRow extends StatelessWidget {
  448. const AssetAccountRow({
  449. super.key,
  450. required this.icon,
  451. required this.label,
  452. required this.amount,
  453. required this.usdAmount,
  454. this.onTransfer,
  455. this.onTap,
  456. });
  457. final IconData icon;
  458. final String label;
  459. final String amount;
  460. final String usdAmount;
  461. final VoidCallback? onTransfer;
  462. final VoidCallback? onTap;
  463. @override
  464. Widget build(BuildContext context) {
  465. final cs = Theme.of(context).colorScheme;
  466. final isDark = Theme.of(context).brightness == Brightness.dark;
  467. return GestureDetector(
  468. onTap: onTap,
  469. behavior: HitTestBehavior.opaque,
  470. child: Container(
  471. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  472. decoration: BoxDecoration(
  473. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  474. borderRadius: BorderRadius.circular(12),
  475. border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
  476. ),
  477. child: Row(
  478. children: [
  479. Icon(icon, color: cs.onSurface.withAlpha(153), size: 22),
  480. const SizedBox(width: 12),
  481. Expanded(
  482. child: Text(label,
  483. style: TextStyle(
  484. color: cs.onSurface,
  485. fontSize: 14,
  486. fontWeight: FontWeight.w500)),
  487. ),
  488. if (onTransfer != null) ...[
  489. OutlinedButton(
  490. onPressed: onTransfer,
  491. style: OutlinedButton.styleFrom(
  492. side: const BorderSide(color: AppColors.brand),
  493. foregroundColor: AppColors.brand,
  494. minimumSize: const Size(0, 32),
  495. padding: const EdgeInsets.symmetric(horizontal: 12),
  496. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  497. ),
  498. child: Text(
  499. AppLocalizations.of(context)!.transfer,
  500. style: const TextStyle(fontSize: 12),
  501. ),
  502. ),
  503. const SizedBox(width: 12),
  504. ],
  505. Column(
  506. crossAxisAlignment: CrossAxisAlignment.end,
  507. children: [
  508. Text(amount, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  509. if (usdAmount.isNotEmpty)
  510. Text(usdAmount, style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12)),
  511. ],
  512. ),
  513. const SizedBox(width: 8),
  514. Icon(
  515. Icons.chevron_right,
  516. size: 18,
  517. color: cs.onSurface.withAlpha(102),
  518. ),
  519. ],
  520. ),
  521. ),
  522. );
  523. }
  524. }
  525. // ── 内部私有组件 ──────────────────────────────────────────
  526. class _Tag extends StatelessWidget {
  527. const _Tag({required this.label});
  528. final String label;
  529. @override
  530. Widget build(BuildContext context) {
  531. final cs = Theme.of(context).colorScheme;
  532. return Container(
  533. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  534. decoration: BoxDecoration(
  535. border: Border.all(color: cs.onSurface.withAlpha(60)),
  536. borderRadius: BorderRadius.circular(4),
  537. ),
  538. child: Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 10)),
  539. );
  540. }
  541. }
  542. class _DataGrid extends StatelessWidget {
  543. const _DataGrid({required this.obscure, required this.rows});
  544. final bool obscure;
  545. final List<List<String>> rows;
  546. @override
  547. Widget build(BuildContext context) {
  548. final cs = Theme.of(context).colorScheme;
  549. return Column(
  550. children: rows.map((row) {
  551. return Padding(
  552. padding: const EdgeInsets.only(bottom: 12),
  553. child: Row(
  554. children: [
  555. for (int i = 0; i < row.length; i += 2) ...[
  556. Expanded(
  557. child: Column(
  558. crossAxisAlignment: i == 0
  559. ? CrossAxisAlignment.start
  560. : i + 2 >= row.length
  561. ? CrossAxisAlignment.end
  562. : CrossAxisAlignment.center,
  563. children: [
  564. Text(row[i],
  565. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
  566. textAlign: i == 0
  567. ? TextAlign.left
  568. : i + 2 >= row.length
  569. ? TextAlign.right
  570. : TextAlign.center),
  571. const SizedBox(height: 2),
  572. Text(
  573. obscure ? '***' : row[i + 1],
  574. style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
  575. textAlign: i == 0
  576. ? TextAlign.left
  577. : i + 2 >= row.length
  578. ? TextAlign.right
  579. : TextAlign.center,
  580. ),
  581. ],
  582. ),
  583. ),
  584. ],
  585. ],
  586. ),
  587. );
  588. }).toList(),
  589. );
  590. }
  591. }