| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591 |
- import 'package:flutter/material.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/number_format.dart';
- import '../../../providers/asset_provider.dart';
- import '../../../providers/my_copy_trading_provider.dart';
- import '../../widgets/common/app_shimmer.dart';
- import '../../widgets/common/app_tab_bar.dart';
- import '../../widgets/common/network_error_body.dart';
- import 'asset_overview_tab.dart';
- import 'asset_futures_tab.dart';
- import 'asset_copy_trading_tab.dart';
- import 'asset_spot_tab.dart';
- import 'asset_spot_trading_tab.dart';
- class AssetScreen extends ConsumerStatefulWidget {
- const AssetScreen({super.key});
- @override
- ConsumerState<AssetScreen> createState() => _AssetScreenState();
- }
- class _AssetScreenState extends ConsumerState<AssetScreen>
- with SingleTickerProviderStateMixin {
- late TabController _tabController;
- late PageController _pageController;
- List<String> _getLocalizedTabs(BuildContext context) {
- final l10n = AppLocalizations.of(context)!;
- return [
- l10n.assetOverview,
- l10n.fund,
- l10n.spotTab,
- l10n.futures,
- l10n.copyTrading,
- ];
- }
- @override
- void initState() {
- super.initState();
- _tabController = TabController(length: 5, vsync: this);
- _pageController = PageController();
- // 拖动 PageView → 实时更新指示器 offset(平滑插值)
- _pageController.addListener(() {
- if (!_pageController.hasClients) return;
- final page = _pageController.page!;
- final offset = page - _tabController.index;
- if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
- _tabController.offset = offset.clamp(-1.0, 1.0);
- }
- });
- _tabController.addListener(_onTabChanged);
- }
- void _onTabChanged() {
- if (_tabController.indexIsChanging) {
- // Tab 点击 → 驱动 PageView 动画
- _pageController.animateToPage(
- _tabController.index,
- duration: const Duration(milliseconds: 280),
- curve: Curves.easeOut,
- );
- } else {
- // Tab 切换完成:更新当前子 tab 索引并按需刷新
- final idx = _tabController.index;
- final prevIdx = ref.read(currentAssetSubTabProvider);
- ref.read(currentAssetSubTabProvider.notifier).state = idx;
- if (prevIdx == 2 && idx != 2) {
- ref.read(assetProvider.notifier).onSpotTradingTabHidden();
- }
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) _refreshForTab(idx);
- });
- // 仅合约 tab(索引 3)需要定时拉持仓;现货 tab 首次进入 HTTP,后续走 WS
- final notifier = ref.read(assetProvider.notifier);
- if (idx == 3) {
- notifier.startPositionPolling();
- } else {
- notifier.stopPositionPolling();
- }
- }
- setState(() {});
- }
- void _refreshForTab(int tabIndex) {
- final n = ref.read(assetProvider.notifier);
- if (tabIndex == 2) {
- n.onSpotTradingTabVisible();
- } else {
- n.silentRefresh();
- }
- if (tabIndex == 4) {
- // 跟单 tab 额外刷新跟单仓位
- ref.read(myCopyTradingProvider.notifier).silentRefresh();
- }
- }
- @override
- void dispose() {
- ref.read(assetProvider.notifier).onSpotTradingTabHidden();
- ref.read(assetProvider.notifier).stopPositionPolling();
- _tabController.dispose();
- _pageController.dispose();
- super.dispose();
- }
- String _getTabLabel(int index) {
- const labels = [
- 'asset_tab_overview',
- 'asset_tab_funds',
- 'asset_tab_spot_trading',
- 'asset_tab_futures',
- 'asset_tab_copy_trading',
- ];
- return labels[index];
- }
- @override
- Widget build(BuildContext context) {
- final state = ref.watch(assetProvider);
- final notifier = ref.read(assetProvider.notifier);
- return Scaffold(
- body: SafeArea(
- child: Column(
- children: [
- // ── Tab 栏 ─────────────────────────────────────
- TabBar(
- controller: _tabController,
- isScrollable: true,
- indicator: StretchTabIndicator(
- controller: _tabController,
- color: AppColors.brand,
- height: 3,
- borderRadius: 1.5,
- ),
- indicatorSize: TabBarIndicatorSize.label,
- labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
- unselectedLabelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
- tabAlignment: TabAlignment.start,
- dividerColor: Colors.transparent,
- labelPadding: const EdgeInsets.symmetric(horizontal: 14),
- padding: const EdgeInsets.fromLTRB(4, 8, 16, 0),
- tabs: <Widget>[
- for (int i = 0; i < 5; i++)
- Semantics(
- label: _getTabLabel(i),
- button: true,
- child: Tab(text: _getLocalizedTabs(context)[i]),
- ),
- ],
- ),
- // ── Tab 内容 ───────────────────────────────────
- Expanded(
- child: _buildContent(state, notifier),
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildContent(AssetState state, AssetNotifier notifier) {
- if (state.isLoading) return const _AssetShimmer();
- if (state.errorMessage != null) {
- return NetworkErrorBody(onRetry: notifier.loadAssets);
- }
- return PageView(
- controller: _pageController,
- physics: const BouncingScrollPhysics(
- parent: AlwaysScrollableScrollPhysics(),
- ),
- onPageChanged: (index) {
- if (_tabController.indexIsChanging) return;
- _tabController.index = index;
- },
- children: [
- AssetOverviewTab(state: state, notifier: notifier),
- AssetSpotTab(state: state, notifier: notifier),
- AssetSpotTradingTab(state: state, notifier: notifier),
- AssetFuturesTab(state: state, notifier: notifier),
- AssetCopyTradingTab(state: state, notifier: notifier),
- ],
- );
- }
- }
- // ── 资产骨架屏 ──────────────────────────────────────────────
- class _AssetShimmer extends StatelessWidget {
- const _AssetShimmer();
- @override
- Widget build(BuildContext context) {
- return AppShimmer(
- child: SingleChildScrollView(
- physics: const NeverScrollableScrollPhysics(),
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 资产估值头部
- shimmerBox(80, 13),
- const SizedBox(height: 10),
- shimmerBox(180, 34),
- const SizedBox(height: 6),
- shimmerBox(120, 13),
- const SizedBox(height: 24),
- // 账户卡片列表
- ...List.generate(3, (_) => Padding(
- padding: const EdgeInsets.only(bottom: 12),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Row(
- children: [
- shimmerCircle(22),
- const SizedBox(width: 12),
- shimmerBox(80, 14),
- const Spacer(),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- shimmerBox(90, 15),
- const SizedBox(height: 4),
- shimmerBox(60, 12),
- ],
- ),
- ],
- ),
- ),
- )),
- const SizedBox(height: 8),
- // 持仓卡片
- Container(
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [shimmerBox(100, 14), shimmerBox(60, 14)],
- ),
- const SizedBox(height: 12),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: List.generate(3, (_) => shimmerBox(70, 40, radius: 6)),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 共享组件(供各 Tab 文件使用)
- // ══════════════════════════════════════════════════════════════
- /// 各 Tab 通用的资产估值头部
- class AssetHeader extends StatelessWidget {
- const AssetHeader({super.key, required this.state, required this.notifier});
- final AssetState state;
- final AssetNotifier notifier;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final total = state.totalUsdtValue;
- final display = state.obscureBalance ? '******' : formatPrice(total, decimalPlaces: 2);
- return Padding(
- padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Text(AppLocalizations.of(context)!.assetValuation, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
- const SizedBox(width: 6),
- Semantics(
- label: 'asset_btn_toggle_balance',
- button: true,
- enabled: true,
- onTap: notifier.toggleObscure,
- child: GestureDetector(
- onTap: notifier.toggleObscure,
- child: Icon(
- state.obscureBalance ? Icons.visibility_off_outlined : Icons.visibility_outlined,
- size: 16, color: cs.onSurface.withAlpha(153),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Semantics(
- label: 'asset_text_total_value',
- enabled: true,
- child: Text(display, style: TextStyle(color: cs.onSurface, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
- ),
- const SizedBox(width: 6),
- Semantics(
- label: 'asset_text_currency',
- enabled: true,
- child: Padding(
- padding: const EdgeInsets.only(bottom: 5),
- child: Text('USDT', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- /// 合约/跟单持仓卡片
- class AssetPositionCard extends StatelessWidget {
- const AssetPositionCard({
- super.key,
- required this.obscure,
- this.symbol = 'BTCUSDT',
- this.isLong = true,
- this.positionType = '',
- this.leverage = '20X',
- this.pnl = '+24.35',
- this.pnlColor,
- this.roi = '+5.36%',
- this.rows = const [],
- this.onShare,
- });
- final bool obscure;
- final String symbol;
- final bool isLong;
- final String positionType;
- final String leverage;
- final String pnl;
- final Color? pnlColor;
- final String roi;
- final List<List<String>> rows;
- final VoidCallback? onShare;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final sideColor = isLong ? AppColors.rise : AppColors.fall;
- final effectivePnlColor = pnlColor ?? AppColors.rise;
- return Container(
- margin: const EdgeInsets.symmetric(horizontal: 16),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 头部:币种 + 标签
- Row(
- children: [
- Container(
- width: 32, height: 32,
- decoration: BoxDecoration(color: sideColor, borderRadius: BorderRadius.circular(8)),
- child: Center(
- child: Text(isLong ? AppLocalizations.of(context)!.longLabel : AppLocalizations.of(context)!.shortLabel,
- style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700)),
- ),
- ),
- const SizedBox(width: 10),
- Text(symbol, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
- const SizedBox(width: 8),
- _Tag(label: AppLocalizations.of(context)!.perpetual),
- const SizedBox(width: 4),
- _Tag(label: positionType),
- const SizedBox(width: 4),
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(20),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(leverage, style: TextStyle(color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600)),
- ),
- const Spacer(),
- GestureDetector(
- onTap: onShare,
- child: Icon(Icons.share_outlined, size: 18, color: cs.onSurface.withAlpha(153)),
- ),
- ],
- ),
- const SizedBox(height: 14),
- // 未实现盈亏 + 收益率
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
- decoration: const BoxDecoration(),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('${AppLocalizations.of(context)!.unrealizedPnl} (USDT)', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 2),
- Text(
- obscure ? '******' : pnl,
- style: TextStyle(color: effectivePnlColor, fontSize: 20, fontWeight: FontWeight.w700),
- ),
- ],
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(AppLocalizations.of(context)!.returnRate, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 2),
- Text(roi, style: TextStyle(color: effectivePnlColor, fontSize: 16, fontWeight: FontWeight.w600)),
- ],
- ),
- ],
- ),
- ),
- const SizedBox(height: 14),
- // 数据网格
- _DataGrid(obscure: obscure, rows: rows),
- ],
- ),
- );
- }
- }
- /// 账户行
- class AssetAccountRow extends StatelessWidget {
- const AssetAccountRow({
- super.key,
- required this.icon,
- required this.label,
- required this.amount,
- required this.usdAmount,
- this.onTransfer,
- });
- final IconData icon;
- final String label;
- final String amount;
- final String usdAmount;
- final VoidCallback? onTransfer;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- border: isDark ? null : Border.all(color: AppColors.lightBorder, width: 0.5),
- ),
- child: Row(
- children: [
- Icon(icon, color: cs.onSurface.withAlpha(153), size: 22),
- const SizedBox(width: 12),
- Expanded(
- child: Text(label,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w500)),
- ),
- if (onTransfer != null) ...[
- OutlinedButton(
- onPressed: onTransfer,
- style: OutlinedButton.styleFrom(
- side: const BorderSide(color: AppColors.brand),
- foregroundColor: AppColors.brand,
- minimumSize: const Size(0, 32),
- padding: const EdgeInsets.symmetric(horizontal: 12),
- tapTargetSize: MaterialTapTargetSize.shrinkWrap,
- ),
- child: Text(
- AppLocalizations.of(context)!.transfer,
- style: const TextStyle(fontSize: 12),
- ),
- ),
- const SizedBox(width: 12),
- ],
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(amount, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
- if (usdAmount.isNotEmpty)
- Text(usdAmount, style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12)),
- ],
- ),
- ],
- ),
- );
- }
- }
- // ── 内部私有组件 ──────────────────────────────────────────
- class _Tag extends StatelessWidget {
- const _Tag({required this.label});
- final String label;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
- decoration: BoxDecoration(
- border: Border.all(color: cs.onSurface.withAlpha(60)),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 10)),
- );
- }
- }
- class _DataGrid extends StatelessWidget {
- const _DataGrid({required this.obscure, required this.rows});
- final bool obscure;
- final List<List<String>> rows;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Column(
- children: rows.map((row) {
- return Padding(
- padding: const EdgeInsets.only(bottom: 12),
- child: Row(
- children: [
- for (int i = 0; i < row.length; i += 2) ...[
- Expanded(
- child: Column(
- crossAxisAlignment: i == 0
- ? CrossAxisAlignment.start
- : i + 2 >= row.length
- ? CrossAxisAlignment.end
- : CrossAxisAlignment.center,
- children: [
- Text(row[i],
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
- textAlign: i == 0
- ? TextAlign.left
- : i + 2 >= row.length
- ? TextAlign.right
- : TextAlign.center),
- const SizedBox(height: 2),
- Text(
- obscure ? '***' : row[i + 1],
- style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
- textAlign: i == 0
- ? TextAlign.left
- : i + 2 >= row.length
- ? TextAlign.right
- : TextAlign.center,
- ),
- ],
- ),
- ),
- ],
- ],
- ),
- );
- }).toList(),
- );
- }
- }
|