asset_screen.dart 21 KB

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