asset_screen.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  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. const SizedBox(width: 8),
  220. shimmerBox(18, 18, radius: 4),
  221. ],
  222. ),
  223. ),
  224. )),
  225. const SizedBox(height: 8),
  226. // 持仓卡片
  227. Container(
  228. padding: const EdgeInsets.all(16),
  229. decoration: BoxDecoration(
  230. color: Colors.white,
  231. borderRadius: BorderRadius.circular(12),
  232. ),
  233. child: Column(
  234. children: [
  235. Row(
  236. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  237. children: [shimmerBox(100, 14), shimmerBox(60, 14)],
  238. ),
  239. const SizedBox(height: 12),
  240. Row(
  241. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  242. children: List.generate(3, (_) => shimmerBox(70, 40, radius: 6)),
  243. ),
  244. ],
  245. ),
  246. ),
  247. ],
  248. ),
  249. ),
  250. );
  251. }
  252. }
  253. // ══════════════════════════════════════════════════════════════
  254. // 共享组件(供各 Tab 文件使用)
  255. // ══════════════════════════════════════════════════════════════
  256. /// 各 Tab 通用的资产估值头部
  257. class AssetHeader extends StatelessWidget {
  258. const AssetHeader({super.key, required this.state, required this.notifier});
  259. final AssetState state;
  260. final AssetNotifier notifier;
  261. @override
  262. Widget build(BuildContext context) {
  263. final cs = Theme.of(context).colorScheme;
  264. final total = state.totalUsdtValue;
  265. final display = state.obscureBalance ? '******' : formatPrice(total, decimalPlaces: 2);
  266. return Padding(
  267. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  268. child: Column(
  269. crossAxisAlignment: CrossAxisAlignment.start,
  270. children: [
  271. Row(
  272. children: [
  273. Text(AppLocalizations.of(context)!.assetValuation, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  274. const SizedBox(width: 6),
  275. Semantics(
  276. label: 'asset_btn_toggle_balance',
  277. button: true,
  278. enabled: true,
  279. onTap: notifier.toggleObscure,
  280. child: GestureDetector(
  281. onTap: notifier.toggleObscure,
  282. child: Icon(
  283. state.obscureBalance ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  284. size: 16, color: cs.onSurface.withAlpha(153),
  285. ),
  286. ),
  287. ),
  288. ],
  289. ),
  290. const SizedBox(height: 8),
  291. Row(
  292. crossAxisAlignment: CrossAxisAlignment.end,
  293. children: [
  294. Semantics(
  295. label: 'asset_text_total_value',
  296. enabled: true,
  297. child: Text(display, style: TextStyle(color: cs.onSurface, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
  298. ),
  299. const SizedBox(width: 6),
  300. Semantics(
  301. label: 'asset_text_currency',
  302. enabled: true,
  303. child: Padding(
  304. padding: const EdgeInsets.only(bottom: 5),
  305. child: Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  306. ),
  307. ),
  308. ],
  309. ),
  310. ],
  311. ),
  312. );
  313. }
  314. }
  315. /// 合约/跟单持仓卡片
  316. class AssetPositionCard extends StatelessWidget {
  317. const AssetPositionCard({
  318. super.key,
  319. required this.obscure,
  320. this.symbol = 'BTCUSDT',
  321. this.isLong = true,
  322. this.positionType = '',
  323. this.leverage = '20X',
  324. this.pnl = '+24.35',
  325. this.pnlColor,
  326. this.roi = '+5.36%',
  327. this.rows = const [],
  328. this.onShare,
  329. });
  330. final bool obscure;
  331. final String symbol;
  332. final bool isLong;
  333. final String positionType;
  334. final String leverage;
  335. final String pnl;
  336. final Color? pnlColor;
  337. final String roi;
  338. final List<List<String>> rows;
  339. final VoidCallback? onShare;
  340. @override
  341. Widget build(BuildContext context) {
  342. final cs = Theme.of(context).colorScheme;
  343. final isDark = Theme.of(context).brightness == Brightness.dark;
  344. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  345. final effectivePnlColor = pnlColor ?? AppColors.rise;
  346. return Container(
  347. margin: const EdgeInsets.symmetric(horizontal: 16),
  348. padding: const EdgeInsets.all(16),
  349. decoration: BoxDecoration(
  350. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  351. borderRadius: BorderRadius.circular(12),
  352. border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
  353. ),
  354. child: Column(
  355. crossAxisAlignment: CrossAxisAlignment.start,
  356. children: [
  357. // 头部:币种 + 标签
  358. Row(
  359. children: [
  360. Container(
  361. width: 32, height: 32,
  362. decoration: BoxDecoration(color: sideColor, borderRadius: BorderRadius.circular(8)),
  363. child: Center(
  364. child: Text(isLong ? AppLocalizations.of(context)!.longLabel : AppLocalizations.of(context)!.shortLabel,
  365. style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700)),
  366. ),
  367. ),
  368. const SizedBox(width: 10),
  369. Text(symbol, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  370. const SizedBox(width: 8),
  371. _Tag(label: AppLocalizations.of(context)!.perpetual),
  372. const SizedBox(width: 4),
  373. _Tag(label: positionType),
  374. const SizedBox(width: 4),
  375. Container(
  376. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  377. decoration: BoxDecoration(
  378. color: cs.onSurface.withAlpha(20),
  379. borderRadius: BorderRadius.circular(4),
  380. ),
  381. child: Text(leverage, style: TextStyle(color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600)),
  382. ),
  383. const Spacer(),
  384. GestureDetector(
  385. onTap: onShare,
  386. child: Icon(Icons.share_outlined, size: 18, color: cs.onSurface.withAlpha(153)),
  387. ),
  388. ],
  389. ),
  390. const SizedBox(height: 14),
  391. // 未实现盈亏 + 收益率
  392. Container(
  393. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
  394. decoration: const BoxDecoration(),
  395. child: Row(
  396. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  397. children: [
  398. Column(
  399. crossAxisAlignment: CrossAxisAlignment.start,
  400. children: [
  401. Text('${AppLocalizations.of(context)!.unrealizedPnl} (USDT)', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  402. const SizedBox(height: 2),
  403. Text(
  404. obscure ? '******' : pnl,
  405. style: TextStyle(color: effectivePnlColor, fontSize: 20, fontWeight: FontWeight.w700),
  406. ),
  407. ],
  408. ),
  409. Column(
  410. crossAxisAlignment: CrossAxisAlignment.end,
  411. children: [
  412. Text(AppLocalizations.of(context)!.returnRate, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  413. const SizedBox(height: 2),
  414. Text(roi, style: TextStyle(color: effectivePnlColor, fontSize: 16, fontWeight: FontWeight.w600)),
  415. ],
  416. ),
  417. ],
  418. ),
  419. ),
  420. const SizedBox(height: 14),
  421. // 数据网格
  422. _DataGrid(obscure: obscure, rows: rows),
  423. ],
  424. ),
  425. );
  426. }
  427. }
  428. /// 账户行
  429. class AssetAccountRow extends StatelessWidget {
  430. const AssetAccountRow({
  431. super.key,
  432. required this.icon,
  433. required this.label,
  434. required this.amount,
  435. required this.usdAmount,
  436. this.onTransfer,
  437. });
  438. final IconData icon;
  439. final String label;
  440. final String amount;
  441. final String usdAmount;
  442. final VoidCallback? onTransfer;
  443. @override
  444. Widget build(BuildContext context) {
  445. final cs = Theme.of(context).colorScheme;
  446. final isDark = Theme.of(context).brightness == Brightness.dark;
  447. return Container(
  448. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  449. decoration: BoxDecoration(
  450. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  451. borderRadius: BorderRadius.circular(12),
  452. border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
  453. ),
  454. child: Row(
  455. children: [
  456. Icon(icon, color: cs.onSurface.withAlpha(153), size: 22),
  457. const SizedBox(width: 12),
  458. Expanded(
  459. child: Text(label,
  460. style: TextStyle(
  461. color: cs.onSurface,
  462. fontSize: 14,
  463. fontWeight: FontWeight.w500)),
  464. ),
  465. if (onTransfer != null) ...[
  466. OutlinedButton(
  467. onPressed: onTransfer,
  468. style: OutlinedButton.styleFrom(
  469. side: const BorderSide(color: AppColors.brand),
  470. foregroundColor: AppColors.brand,
  471. minimumSize: const Size(0, 32),
  472. padding: const EdgeInsets.symmetric(horizontal: 12),
  473. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  474. ),
  475. child: Text(
  476. AppLocalizations.of(context)!.transfer,
  477. style: const TextStyle(fontSize: 12),
  478. ),
  479. ),
  480. const SizedBox(width: 12),
  481. ],
  482. Column(
  483. crossAxisAlignment: CrossAxisAlignment.end,
  484. children: [
  485. Text(amount, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  486. if (usdAmount.isNotEmpty)
  487. Text(usdAmount, style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12)),
  488. ],
  489. ),
  490. const SizedBox(width: 8),
  491. Icon(
  492. Icons.chevron_right,
  493. size: 18,
  494. color: cs.onSurface.withAlpha(102),
  495. ),
  496. ],
  497. ),
  498. );
  499. }
  500. }
  501. // ── 内部私有组件 ──────────────────────────────────────────
  502. class _Tag extends StatelessWidget {
  503. const _Tag({required this.label});
  504. final String label;
  505. @override
  506. Widget build(BuildContext context) {
  507. final cs = Theme.of(context).colorScheme;
  508. return Container(
  509. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  510. decoration: BoxDecoration(
  511. border: Border.all(color: cs.onSurface.withAlpha(60)),
  512. borderRadius: BorderRadius.circular(4),
  513. ),
  514. child: Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 10)),
  515. );
  516. }
  517. }
  518. class _DataGrid extends StatelessWidget {
  519. const _DataGrid({required this.obscure, required this.rows});
  520. final bool obscure;
  521. final List<List<String>> rows;
  522. @override
  523. Widget build(BuildContext context) {
  524. final cs = Theme.of(context).colorScheme;
  525. return Column(
  526. children: rows.map((row) {
  527. return Padding(
  528. padding: const EdgeInsets.only(bottom: 12),
  529. child: Row(
  530. children: [
  531. for (int i = 0; i < row.length; i += 2) ...[
  532. Expanded(
  533. child: Column(
  534. crossAxisAlignment: i == 0
  535. ? CrossAxisAlignment.start
  536. : i + 2 >= row.length
  537. ? CrossAxisAlignment.end
  538. : CrossAxisAlignment.center,
  539. children: [
  540. Text(row[i],
  541. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
  542. textAlign: i == 0
  543. ? TextAlign.left
  544. : i + 2 >= row.length
  545. ? TextAlign.right
  546. : TextAlign.center),
  547. const SizedBox(height: 2),
  548. Text(
  549. obscure ? '***' : row[i + 1],
  550. style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
  551. textAlign: i == 0
  552. ? TextAlign.left
  553. : i + 2 >= row.length
  554. ? TextAlign.right
  555. : TextAlign.center,
  556. ),
  557. ],
  558. ),
  559. ),
  560. ],
  561. ],
  562. ),
  563. );
  564. }).toList(),
  565. );
  566. }
  567. }