import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/number_format.dart'; import '../../../core/utils/spot_order_book_convert.dart'; import '../../../core/utils/symbol_display.dart'; import '../../../core/utils/top_toast.dart'; import '../../../providers/auth_provider.dart'; import '../../../providers/futures_provider.dart'; import '../../../providers/spot_coin_cache_provider.dart'; import '../../../providers/spot_provider.dart'; import '../../widgets/common/app_refresh_indicator.dart'; import '../../widgets/common/app_shimmer.dart'; import '../../widgets/common/coin_icon.dart'; import '../../widgets/common/kline_toolbar_icon.dart'; import '../../widgets/common/symbol_picker_sheet.dart'; /// 现货交易主页(与合约页风格保持一致) class SpotScreen extends ConsumerStatefulWidget { const SpotScreen({super.key, required this.symbol}); final String symbol; @override ConsumerState createState() => _SpotScreenState(); } class _SpotScreenState extends ConsumerState { late final ScrollController _scroll; final _orderPanelKey = GlobalKey<_SpotOrderPanelState>(); int _obRowCount = 7; double _obRowH = 22.0; double _leftPanelHeight = 520.0; void _onLeftPanelHeight(double h) { // 表头、中间价、底部分布条+占比+模式按钮的预留高度(略收紧以便多挤 1 档) const countFixedH = 128.0; // 每侧一行约 40px(含行高与视觉间距),比原 44 略紧以填满右侧与左侧对齐 final n = ((h - countFixedH) / 40).floor().clamp(4, 14); final rh = ((h - countFixedH) / (n * 2)).clamp(18.0, 28.0); if (n != _obRowCount || (rh - _obRowH).abs() > 0.1 || (h - _leftPanelHeight).abs() > 1) { setState(() { _obRowCount = n; _obRowH = rh; _leftPanelHeight = h; }); } } @override void initState() { super.initState(); _scroll = ScrollController(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(spotActiveSymbolProvider.notifier).state = widget.symbol; ref.read(lastTradingRouteProvider.notifier).state = '/spot/${widget.symbol}'; }); } @override void dispose() { _scroll.dispose(); super.dispose(); } Future _pushAndPausePolling(BuildContext context, String path) async { final notifier = ref.read(spotProvider(widget.symbol).notifier); notifier.stopPolling(); await context.push(path); if (mounted) notifier.resumePolling(); } @override Widget build(BuildContext context) { final symbol = widget.symbol; final cs = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( elevation: 0, toolbarHeight: 44, titleSpacing: 16, title: _SegmentedTabHeader( activeIndex: 0, onTap: (i) { if (i == 1) { final futuresSym = ref.read(futuresActiveSymbolProvider); context.go( '/futures/${futuresSym.isNotEmpty ? futuresSym : 'BTCUSDT'}'); } }, ), centerTitle: false, bottom: PreferredSize( preferredSize: const Size.fromHeight(1), child: Container(height: 1, color: cs.outline.withAlpha(40)), ), actions: [ IconButton( icon: KlineToolbarIcon(color: cs.onSurface.withAlpha(180)), onPressed: () => _pushAndPausePolling(context, '/market/spot/$symbol'), padding: const EdgeInsets.symmetric(horizontal: 8), constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), ], ), body: Listener( onPointerDown: (_) => FocusScope.of(context).unfocus(), child: Builder(builder: (ctx) { final isLoading = ref.watch(spotProvider(symbol).select((s) => s.isLoading)); if (isLoading) return const _SpotShimmer(); return AppRefreshIndicator( onRefresh: () => ref.read(spotProvider(symbol).notifier).refresh(), child: SingleChildScrollView( controller: _scroll, physics: const ClampingScrollPhysics(), child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 55, child: _SizeReporter( onHeight: _onLeftPanelHeight, child: DecoratedBox( decoration: BoxDecoration( border: Border( right: BorderSide( color: cs.outline.withAlpha(40), width: 1, ), ), ), child: _SpotOrderPanel( key: _orderPanelKey, symbol: symbol, ), ), ), ), Expanded( flex: 45, child: SizedBox( height: _leftPanelHeight, child: RepaintBoundary( child: _SpotOrderBookPanel( symbol: symbol, rowCount: _obRowCount, rowHeight: _obRowH, onPriceTap: (price) => _orderPanelKey.currentState ?.setBookPrice(price), ), ), ), ), ], ), const SizedBox(height: 8), _SpotBottomSection(symbol: symbol), const SizedBox(height: 16), ], ), ), ); }), ), ); } } // ══════════════════════════════════════════════════════════════════════ // 顶部 现货 / 永续合约 切换 Tab(共用:Spot/Futures 标题区均使用此组件) // ══════════════════════════════════════════════════════════════════════ class _SegmentedTabHeader extends StatelessWidget { const _SegmentedTabHeader({required this.activeIndex, required this.onTap}); final int activeIndex; // 0=现货 1=合约 final ValueChanged onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final items = [l10n.spotTab, l10n.perpetualContract]; return Row( mainAxisSize: MainAxisSize.min, children: List.generate(items.length, (i) { final isActive = i == activeIndex; return Padding( padding: EdgeInsets.only(right: i == 0 ? 16 : 0), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onTap(i), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( items[i], style: TextStyle( color: isActive ? cs.onSurface : cs.onSurface.withAlpha(140), fontSize: 16, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, ), ), const SizedBox(height: 4), Container( width: 28, height: 3, color: isActive ? AppColors.brand : Colors.transparent, ), ], ), ), ); }), ); } } // ══════════════════════════════════════════════════════════════════════ // 下单面板 // ══════════════════════════════════════════════════════════════════════ class _SpotOrderPanel extends ConsumerStatefulWidget { const _SpotOrderPanel({super.key, required this.symbol}); final String symbol; @override ConsumerState<_SpotOrderPanel> createState() => _SpotOrderPanelState(); } class _SpotOrderPanelState extends ConsumerState<_SpotOrderPanel> { final _priceCtrl = TextEditingController(); final _amountCtrl = TextEditingController(); final _triggerCtrl = TextEditingController(); bool _priceFilled = false; @override void initState() { super.initState(); _amountCtrl.addListener(_onAmountChanged); } @override void dispose() { _amountCtrl.removeListener(_onAmountChanged); _priceCtrl.dispose(); _amountCtrl.dispose(); _triggerCtrl.dispose(); super.dispose(); } // 输入数量时反向同步滑块(仅作展示,避免与滑块冲突) bool _settingFromSlider = false; void _onAmountChanged() { if (_settingFromSlider) return; final notifier = ref.read(spotProvider(widget.symbol).notifier); final s = ref.read(spotProvider(widget.symbol)); final v = double.tryParse(_amountCtrl.text.replaceAll(',', '')) ?? 0; if (v <= 0) { notifier.setSliderPercent(0); return; } final max = _maxAmount(s); notifier.setSliderPercent(max > 0 ? (v / max).clamp(0.0, 1.0) : 0); } /// 当前用户可下单的"数量上限"(按 effectiveAmountUnit 计) double _maxAmount(SpotState s) { final price = _refPrice(s); if (s.side == SpotSide.buy) { // 买入:用 USDT 余额 final usdt = s.availableQuote; if (s.effectiveAmountUnit == SpotAmountUnit.quote) return usdt; // base 单位:折算为基础币数量(需要参考价) if (price <= 0) return 0; return usdt / price; } // 卖出:用 base 余额 final base = s.availableBase; if (s.effectiveAmountUnit == SpotAmountUnit.base) return base; if (price <= 0) return 0; return base * price; } /// 计算/换算时使用的参考价 /// - 限价 / 计划限价:价格输入框 /// - 计划市价:触发价 /// - 市价:最新价 double _refPrice(SpotState s) { if (s.orderType == SpotOrderType.limit) { return double.tryParse(_priceCtrl.text.replaceAll(',', '')) ?? 0; } if (s.orderType == SpotOrderType.conditionalMarket) { return double.tryParse(_triggerCtrl.text.replaceAll(',', '')) ?? 0; } return s.lastPrice; } void setBookPrice(double price) { if (!mounted) return; final s = ref.read(spotProvider(widget.symbol)); final formatted = price.toStringAsFixed(s.pricePrecision); if (s.orderType == SpotOrderType.conditionalMarket) { _triggerCtrl.text = formatted; } else if (s.orderType == SpotOrderType.limit) { _priceCtrl.text = formatted; } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final provider = spotProvider(widget.symbol); final notifier = ref.read(provider.notifier); final isLoggedIn = ref.watch(isLoggedInProvider); // 切换下单类型时清空所有输入 ref.listen(provider.select((s) => s.orderType), (prev, next) { if (prev == next) return; _priceFilled = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; FocusManager.instance.primaryFocus?.unfocus(); _priceCtrl.clear(); _amountCtrl.clear(); _triggerCtrl.clear(); notifier.setSliderPercent(0); }); }); // 切换买/卖时清空数量与滑块(保留价格) ref.listen(provider.select((s) => s.side), (prev, next) { if (prev == next) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _amountCtrl.clear(); notifier.setSliderPercent(0); }); }); // 限价单首次拿到价格时回填 ref.listen(provider.select((s) => s.lastPrice), (_, next) { if (_priceFilled || next <= 0) return; _priceFilled = true; final precision = ref.read(provider).pricePrecision; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_priceCtrl.text.isEmpty) { _priceCtrl.text = next.toStringAsFixed(precision); } }); }); final symbolValue = ref.watch(provider.select((s) => s.symbol)); final change = ref.watch(provider.select((s) => s.change24h)); final orderType = ref.watch(provider.select((s) => s.orderType)); final side = ref.watch(provider.select((s) => s.side)); final unit = ref.watch(provider.select((s) => s.effectiveAmountUnit)); final showPriceInput = ref.watch(provider.select((s) => s.showPriceInput)); final showTrigger = ref.watch(provider.select((s) => s.showTriggerPrice)); final lastPrice = ref.watch(provider.select((s) => s.lastPrice)); final availQuote = ref.watch(provider.select((s) => s.availableQuote)); final availBase = ref.watch(provider.select((s) => s.availableBase)); final base = ref.watch(provider.select((s) => s.baseCoin)); final quote = ref.watch(provider.select((s) => s.quoteCoin)); final pricePre = ref.watch(provider.select((s) => s.pricePrecision)); final volPre = ref.watch(provider.select((s) => s.volumePrecision)); final sliderPct = ref.watch(provider.select((s) => s.sliderPercent)); final unitLabel = unit == SpotAmountUnit.quote ? quote : base; final priceFormatter = _PrecisionInputFormatter(pricePre); final amountFormatter = _PrecisionInputFormatter( unit == SpotAmountUnit.quote ? 2 : volPre, ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => _showSymbolPicker(context), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( formatUsdtPairDisplay(symbolValue), overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w700, ), ), ), Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 16), const SizedBox(width: 6), Text( formatChange(change), style: TextStyle( color: AppColors.changeColor(change), fontSize: 12, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ), ), const SizedBox(height: 8), // 买/卖 Tab _BuySellTabs( side: side, onChanged: notifier.setSide, ), const SizedBox(height: 10), // 订单类型下拉 _SpotOrderTypeDropdown(symbol: widget.symbol), const SizedBox(height: 8), // 触发价(仅条件委托) if (showTrigger) ...[ _LargeInput( controller: _triggerCtrl, label: l10n.triggerPrice, unit: quote, inputFormatters: [priceFormatter], ), const SizedBox(height: 8), ], // 价格 输入框 / 市价占位 if (showPriceInput) ...[ _LargeInput( controller: _priceCtrl, label: l10n.priceLabel2, unit: quote, inputFormatters: [priceFormatter], ), const SizedBox(height: 8), ] else ...[ _MarketPriceBox(label: l10n.marketBest, quote: quote), const SizedBox(height: 8), ], // 数量 _LargeInput( controller: _amountCtrl, label: side == SpotSide.buy && orderType != SpotOrderType.limit ? l10n.amountQuoteLabel : l10n.quantityLabel, unit: unitLabel, // 限价单:买入时 BTC/USDT 可切换;卖出时同样;市价/计划市价单位锁定 showUnitDropdown: orderType == SpotOrderType.limit, onUnitTap: orderType == SpotOrderType.limit ? () => _showAmountUnitSheet(context) : null, inputFormatters: [amountFormatter], ), // 总价(市价时根据滑块/数量计算) if (orderType != SpotOrderType.limit && lastPrice > 0) ListenableBuilder( listenable: _amountCtrl, builder: (_, __) { final v = double.tryParse(_amountCtrl.text.replaceAll(',', '')) ?? 0; if (v <= 0) return const SizedBox.shrink(); final estUsdt = side == SpotSide.buy ? v : v * lastPrice; return Padding( padding: const EdgeInsets.only(top: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( formatAmount(estUsdt), style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700, fontFeatures: const [FontFeature.tabularFigures()], ), ), Text( formatFiatPrice(estUsdt), style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11, ), ), ], ), ); }, ), const SizedBox(height: 10), // 滑块 _PercentSlider( percent: sliderPct, onChanged: (pct) { final s = ref.read(provider); if (pct > 0 && _refPrice(s) <= 0 && orderType != SpotOrderType.market) { showTopToast( context, message: orderType == SpotOrderType.conditionalMarket ? l10n.enterTriggerPrice : l10n.enterPrice, backgroundColor: AppColors.fall, ); return; } notifier.setSliderPercent(pct); _settingFromSlider = true; if (pct == 0) { _amountCtrl.clear(); } else { final max = _maxAmount(s); final v = max * pct; final dp = s.effectiveAmountUnit == SpotAmountUnit.quote ? 2 : volPre; final factor = math.pow(10, dp).toDouble(); final truncated = (v * factor).floorToDouble() / factor; _amountCtrl.text = truncated.toStringAsFixed(dp); } _settingFromSlider = false; }, ), const SizedBox(height: 10), // 可用(买入展示计价余额,卖出展示基础币余额) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Text('${l10n.availableLabel} ', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), Text( side == SpotSide.buy ? '${formatAmount(availQuote)} $quote' : '${formatAmount(availBase)} $base', style: TextStyle( color: cs.onSurface, fontSize: 12, fontWeight: FontWeight.w600, ), ), const Spacer(), GestureDetector( onTap: () async { final n = ref.read(provider.notifier); n.stopPolling(); await context.push('/asset/transfer'); if (context.mounted) n.resumePolling(); }, child: Icon(Icons.swap_horiz, color: cs.onSurface.withAlpha(153), size: 16), ), ], ), ), const SizedBox(height: 4), Builder(builder: (_) { final isBuy = side == SpotSide.buy; final String canText; if (isBuy) { final qty = lastPrice > 0 ? availQuote / lastPrice : 0.0; canText = '${formatAmount(qty, decimals: volPre)} $base'; } else { canText = '${formatAmount(availBase, decimals: volPre)} $base'; } return Row( children: [ Text( isBuy ? l10n.canBuy : l10n.canSell, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12), ), const SizedBox(width: 4), Text( canText, style: TextStyle( color: cs.onSurface, fontSize: 12, fontWeight: FontWeight.w600, ), ), ], ); }), const SizedBox(height: 12), // 主操作按钮 SizedBox( width: double.infinity, height: 44, child: ElevatedButton( onPressed: isLoggedIn ? () => _placeOrder(context) : () => context.push('/login'), style: ElevatedButton.styleFrom( backgroundColor: side == SpotSide.buy ? AppColors.rise : AppColors.fall, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), elevation: 0, ), child: Text( side == SpotSide.buy ? l10n.buyCoin(base) : l10n.sellCoin(base), style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700, ), ), ), ), const SizedBox(height: 12), ], ), ); } Future _placeOrder(BuildContext context) async { final notifier = ref.read(spotProvider(widget.symbol).notifier); final s = ref.read(spotProvider(widget.symbol)); final l10n = AppLocalizations.of(context)!; final price = double.tryParse(_priceCtrl.text.replaceAll(',', '')); final inputAmount = double.tryParse(_amountCtrl.text.replaceAll(',', '')) ?? 0; if (s.orderType == SpotOrderType.limit && (price == null || price <= 0)) { showTopToast(context, message: l10n.enterPrice, backgroundColor: AppColors.fall); return; } if (inputAmount <= 0) { showTopToast(context, message: l10n.errEnterAmount, backgroundColor: AppColors.fall); return; } final unit = s.effectiveAmountUnit; final prep = notifier.prepareAmount( side: s.side, type: s.orderType, inputAmount: inputAmount, unit: unit, price: price, ); if (prep == null) { showTopToast(context, message: l10n.errVolumeInsufficient, backgroundColor: AppColors.fall); return; } if (s.orderType == SpotOrderType.conditionalMarket) { showTopToast( context, message: l10n.spotConditionalNotSupported, backgroundColor: AppColors.fall, ); return; } final err = await notifier.placeOrder( side: s.side, type: s.orderType, price: price, amount: prep.payload, ); if (!context.mounted) return; if (err == null) { _amountCtrl.clear(); notifier.setSliderPercent(0); showTopToast( context, message: l10n.orderSuccess, backgroundColor: AppColors.rise, ); } else { showTopToast( context, message: _resolveSpotError(err, l10n), backgroundColor: AppColors.fall, ); } } void _showSymbolPicker(BuildContext context) { FocusScope.of(context).unfocus(); showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetCtx) => SymbolPickerSheet( currentSymbol: widget.symbol, initialTab: SymbolPickerTab.spot, visibleTabs: const [SymbolPickerTab.spot], onSelected: (newSymbol) { Navigator.pop(sheetCtx); context.go('/spot/$newSymbol'); }, onSpotSelected: (newSymbol) { Navigator.pop(sheetCtx); context.go('/spot/$newSymbol'); }, ), ); } void _showAmountUnitSheet(BuildContext context) { FocusScope.of(context).unfocus(); final cs = Theme.of(context).colorScheme; final s = ref.read(spotProvider(widget.symbol)); final notifier = ref.read(spotProvider(widget.symbol).notifier); final units = [ (SpotAmountUnit.base, s.baseCoin), (SpotAmountUnit.quote, s.quoteCoin), ]; showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: cs.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: units .map( (u) => GestureDetector( onTap: () { notifier.setAmountUnit(u.$1); _amountCtrl.clear(); notifier.setSliderPercent(0); Navigator.pop(ctx); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14), child: Row( children: [ Expanded( child: Text(u.$2, style: TextStyle( color: s.amountUnit == u.$1 ? AppColors.brand : cs.onSurface, fontSize: 14)), ), if (s.amountUnit == u.$1) const Icon(Icons.check, color: AppColors.brand, size: 18), ], ), ), ), ) .toList(), ), ), ); } } // ── 买/卖 Tabs ──────────────────────────────────────── class _BuySellTabs extends StatelessWidget { const _BuySellTabs({required this.side, required this.onChanged}); final SpotSide side; final ValueChanged onChanged; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final l10n = AppLocalizations.of(context)!; final bg = isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary; Widget tab({ required SpotSide value, required String label, required Color activeColor, }) { final active = side == value; return Expanded( child: GestureDetector( onTap: () => onChanged(value), behavior: HitTestBehavior.opaque, child: Container( height: 36, decoration: BoxDecoration( color: active ? activeColor : Colors.transparent, borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: Text( label, style: TextStyle( color: active ? Colors.white : cs.onSurface.withAlpha(160), fontSize: 14, fontWeight: FontWeight.w700, ), ), ), ), ); } return Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ tab( value: SpotSide.buy, label: l10n.buyAction, activeColor: AppColors.rise), const SizedBox(width: 4), tab( value: SpotSide.sell, label: l10n.sellAction, activeColor: AppColors.fall), ], ), ); } } // ── 订单类型下拉 ────────────────────────────────────── class _SpotOrderTypeDropdown extends ConsumerWidget { const _SpotOrderTypeDropdown({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final orderType = ref.watch(spotProvider(symbol).select((s) => s.orderType)); final notifier = ref.read(spotProvider(symbol).notifier); final l10n = AppLocalizations.of(context)!; const visibleTypes = [SpotOrderType.market, SpotOrderType.limit]; final safeOrderType = visibleTypes.contains(orderType) ? orderType : SpotOrderType.market; String labelOf(SpotOrderType t) { switch (t) { case SpotOrderType.market: return l10n.marketOrder; case SpotOrderType.limit: return l10n.limitOrder; case SpotOrderType.conditionalMarket: return l10n.marketOrder; } } return GestureDetector( onTap: () { FocusScope.of(context).unfocus(); showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: cs.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetCtx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: visibleTypes.map((t) { final isActive = safeOrderType == t; return GestureDetector( onTap: () { notifier.setOrderType(t); Navigator.pop(sheetCtx); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14), child: Row( children: [ Expanded( child: Text( labelOf(t), style: TextStyle( color: isActive ? AppColors.brand : cs.onSurface, fontSize: 14), ), ), if (isActive) const Icon(Icons.check, color: AppColors.brand, size: 18), ], ), ), ); }).toList(), ), ), ); }, child: Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(labelOf(safeOrderType), style: TextStyle(color: cs.onSurface, fontSize: 14)), Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 18), ], ), ), ); } } // ── 市价占位 ────────────────────────────────────────── class _MarketPriceBox extends StatelessWidget { const _MarketPriceBox({required this.label, required this.quote}); final String label; final String quote; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return Container( height: 44, decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary, borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Expanded( child: Text( label, style: TextStyle( color: cs.onSurface.withAlpha(160), fontSize: 15, fontWeight: FontWeight.w500, ), ), ), Text(quote, style: TextStyle(color: cs.onSurface.withAlpha(150), fontSize: 13)), ], ), ); } } // ══════════════════════════════════════════════════════════════════════ // 通用输入框(与合约页保持一致风格) // ══════════════════════════════════════════════════════════════════════ class _LargeInput extends StatefulWidget { const _LargeInput({ required this.controller, required this.label, required this.unit, this.showUnitDropdown = false, this.onUnitTap, this.inputFormatters, }); final TextEditingController controller; final String label; final String unit; final bool showUnitDropdown; final VoidCallback? onUnitTap; final List? inputFormatters; @override State<_LargeInput> createState() => _LargeInputState(); } class _LargeInputState extends State<_LargeInput> with SingleTickerProviderStateMixin { final _focusNode = FocusNode(); late final AnimationController _animCtrl; late final Animation _curvedAnim; bool get _isActive => _focusNode.hasFocus || widget.controller.text.isNotEmpty; @override void initState() { super.initState(); _animCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), ); _curvedAnim = CurvedAnimation( parent: _animCtrl, curve: const Cubic(0.4, 0.0, 0.2, 1.0), ); _focusNode.addListener(_onChanged); widget.controller.addListener(_onChanged); if (_isActive) _animCtrl.value = 1.0; } @override void didUpdateWidget(_LargeInput oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_onChanged); widget.controller.addListener(_onChanged); _animCtrl.value = _isActive ? 1.0 : 0.0; } } void _onChanged() { if (_isActive) { _animCtrl.forward(); } else { _animCtrl.reverse(); } setState(() {}); } @override void dispose() { _focusNode.removeListener(_onChanged); widget.controller.removeListener(_onChanged); _focusNode.dispose(); _animCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final unfocusedBg = isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary; final focusedBg = isDark ? AppColors.darkBgSecondary : Colors.white; final activeBorder = isDark ? AppColors.darkTextPrimary.withAlpha(200) : const Color(0xFF383838); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _focusNode.requestFocus(), child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: _focusNode.hasFocus ? focusedBg : unfocusedBg, borderRadius: BorderRadius.circular(8), border: _focusNode.hasFocus ? Border.all(color: activeBorder, width: 1.5) : null, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: AnimatedBuilder( animation: _curvedAnim, builder: (context, inputChild) { final t = _curvedAnim.value; final labelSize = 13.0 + (10.0 - 13.0) * t; final labelColor = isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary; final centerTop = (44.0 - labelSize) / 2; const activeTop = 5.0; final labelTop = centerTop + (activeTop - centerTop) * t; return SizedBox( height: 44, child: Stack( clipBehavior: Clip.none, children: [ Positioned( top: labelTop, left: 0, right: 0, child: Text( widget.label, style: TextStyle( color: labelColor, fontSize: labelSize, height: 1.0, ), ), ), Positioned( bottom: 5, left: 0, right: 0, child: Opacity( opacity: t, child: inputChild, ), ), ], ), ); }, child: TextField( focusNode: _focusNode, controller: widget.controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: widget.inputFormatters, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), decoration: const InputDecoration( border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, filled: false, isDense: true, contentPadding: EdgeInsets.zero, ), ), ), ), const SizedBox(width: 6), GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.showUnitDropdown ? () { _focusNode.unfocus(); widget.onUnitTap?.call(); } : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(widget.unit, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), if (widget.showUnitDropdown) Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 14), ], ), ), ], ), ), ); } } // ══════════════════════════════════════════════════════════════════════ // 百分比滑动条(与合约页相同的视觉,简化实现) // ══════════════════════════════════════════════════════════════════════ class _PercentSlider extends StatefulWidget { const _PercentSlider({required this.percent, required this.onChanged}); final double percent; final ValueChanged onChanged; @override State<_PercentSlider> createState() => _PercentSliderState(); } class _PercentSliderState extends State<_PercentSlider> { static const _stops = [0.0, 0.25, 0.5, 0.75, 1.0]; static const _thumbSize = 18.0; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final pct = widget.percent.clamp(0.0, 1.0); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: _thumbSize, child: LayoutBuilder(builder: (_, constraints) { final w = constraints.maxWidth; const r = _thumbSize / 2; final trackW = w - _thumbSize; final thumbX = r + trackW * pct; return GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragUpdate: (d) { final newPct = ((thumbX + d.delta.dx - r) / trackW).clamp(0.0, 1.0); widget.onChanged(newPct); }, onTapDown: (d) { final tapPct = ((d.localPosition.dx - r) / trackW).clamp(0.0, 1.0); HapticFeedback.selectionClick(); widget.onChanged(tapPct); }, child: Stack( children: [ Positioned( top: (_thumbSize - 3) / 2, left: r, right: r, height: 3, child: Container( decoration: BoxDecoration( color: cs.outline.withAlpha(50), borderRadius: BorderRadius.circular(2), ), ), ), Positioned( top: (_thumbSize - 3) / 2, left: r, width: thumbX - r, height: 3, child: Container( decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(2), ), ), ), for (final p in _stops) Positioned( left: r + trackW * p - 3, top: (_thumbSize - 6) / 2, child: Container( width: 6, height: 6, decoration: BoxDecoration( shape: BoxShape.circle, color: p <= pct ? AppColors.brand : cs.outline.withAlpha(80), ), ), ), Positioned( left: thumbX - r, top: 0, width: _thumbSize, height: _thumbSize, child: Container( decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, border: Border.all(color: AppColors.brand, width: 2.5), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(70), blurRadius: 5, spreadRadius: 1, offset: const Offset(0, 1.5), ), ], ), ), ), ], ), ); }), ), const SizedBox(height: 4), LayoutBuilder(builder: (_, constraints) { final w = constraints.maxWidth; const r = _thumbSize / 2; final trackW = w - _thumbSize; final tickInterval = trackW / (_stops.length - 1); final btnW = (tickInterval - 6).clamp(20.0, 64.0); return SizedBox( height: 22, child: Stack( children: [ for (final p in _stops) Positioned( left: (r + trackW * p - btnW / 2).clamp(0.0, w - btnW), top: 0, width: btnW, height: 22, child: Builder(builder: (_) { final selected = (pct - p).abs() < 0.001; return GestureDetector( onTap: () { HapticFeedback.selectionClick(); widget.onChanged(p); }, child: Container( decoration: BoxDecoration( color: selected ? AppColors.brand : cs.outline.withAlpha(25), borderRadius: BorderRadius.circular(4), ), alignment: Alignment.center, child: Text( '${(p * 100).toInt()}%', style: TextStyle( color: selected ? Colors.black : cs.onSurface.withAlpha(102), fontSize: 10, fontWeight: FontWeight.w500, ), ), ), ); }), ), ], ), ); }), ], ); } } // ══════════════════════════════════════════════════════════════════════ // 盘口(布局与合约页 _OrderBookPanel 一致:列标题、深度条、买卖占比、模式切换) // ══════════════════════════════════════════════════════════════════════ class _SpotOrderBookPanel extends ConsumerStatefulWidget { const _SpotOrderBookPanel({ required this.symbol, required this.rowCount, required this.rowHeight, this.onPriceTap, }); final String symbol; final int rowCount; final double rowHeight; final ValueChanged? onPriceTap; @override ConsumerState<_SpotOrderBookPanel> createState() => _SpotOrderBookPanelState(); } class _SpotOrderBookPanelState extends ConsumerState<_SpotOrderBookPanel> { /// 0=双向 1=仅卖 2=仅买 int _bookMode = 0; /// 0=depth0(最少小数位) 1=depth1(中间) 2=depth2(最多小数位) int _depthStep = 2; static String _precisionLabel(int precision) { if (precision <= 0) return '1'; return '0.${'0' * (precision - 1)}1'; } void _showDepthStepSheet(BuildContext context) { final cs = Theme.of(context).colorScheme; final state = ref.read(spotProvider(widget.symbol)); final options = [ (0, state.depth0Pre), (1, state.depth1Pre), (2, state.depth2Pre), ]; showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: cs.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: options.map((opt) { final isActive = _depthStep == opt.$1; return GestureDetector( onTap: () { setState(() => _depthStep = opt.$1); Navigator.pop(ctx); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ Expanded( child: Text( _precisionLabel(opt.$2), style: TextStyle( color: isActive ? AppColors.brand : cs.onSurface, fontSize: 14, ), ), ), if (isActive) const Icon(Icons.check, color: AppColors.brand, size: 18), ], ), ), ); }).toList(), ), ), ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final state = ref.watch(spotProvider(widget.symbol)); final rawAsks = state.orderBookAsks; final rawBids = state.orderBookBids; final n = widget.rowCount; final depthPrecision = _depthStep == 0 ? state.depth0Pre : _depthStep == 1 ? state.depth1Pre : state.depth2Pre; // 合并档位后按价排序:后端买卖盘均为「价高→价低」;分桶合并可能打乱顺序,需恢复单调性 final aggregatedAsks = aggregateSpotDepthLevels(rawAsks, depthPrecision) ..sort((a, b) => spotDepthP(b).compareTo(spotDepthP(a))); final aggregatedBids = aggregateSpotDepthLevels(rawBids, depthPrecision) ..sort((a, b) => spotDepthP(b).compareTo(spotDepthP(a))); // 精度聚合后买一 >= 卖一时,丢弃两端交叉档位 removeCrossingDepthLevels(aggregatedAsks, aggregatedBids); final askTake = _bookMode == 2 ? 0 : (_bookMode == 1 ? n * 2 : n); final bidTake = _bookMode == 1 ? 0 : (_bookMode == 2 ? n * 2 : n); // 卖盘:取最接近成交价的 n 档(聚合后价高→价低序列的末尾 n 条) final askSlice = askTake > 0 && aggregatedAsks.isNotEmpty ? aggregatedAsks.sublist(math.max(0, aggregatedAsks.length - askTake)) : >[]; // 展示须自上而下「价高→价低」(远离中间价 → 靠近卖一);若切片呈升序则反转 final List> askRows = askSlice.length >= 2 && spotDepthP(askSlice.first) < spotDepthP(askSlice.last) ? askSlice.reversed.toList() : List>.from(askSlice); // 买盘:价高在前,首条即最优买 → 取前 n 档,首行紧贴中间价 final bidSlice = bidTake > 0 ? aggregatedBids.take(bidTake).toList() : >[]; final bidRows = bidSlice; double maxQ = 0.001; double totalAsk = 0, totalBid = 0; for (final a in askRows) { final q = spotDepthQ(a); totalAsk += q; if (q > maxQ) maxQ = q; } for (final b in bidRows) { final q = spotDepthQ(b); totalBid += q; if (q > maxQ) maxQ = q; } final totalDepth = totalAsk + totalBid; final bidRatio = totalDepth > 0 ? totalBid / totalDepth : 0.5; final askRatio = 1.0 - bidRatio; final labelStyle = TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11); return Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( l10n.priceUsdt, overflow: TextOverflow.ellipsis, style: labelStyle, ), ), const SizedBox(width: 4), Flexible( child: Text( l10n.amountLabel2(state.baseCoin), overflow: TextOverflow.ellipsis, textAlign: TextAlign.end, style: labelStyle, ), ), ], ), const SizedBox(height: 4), if (_bookMode != 2) for (var i = 0; i < askRows.length; i++) spotDepthP(askRows[i]) > 0 ? _SpotBookRow( key: ValueKey('ask_$i'), isSell: true, price: spotDepthP(askRows[i]), qty: spotDepthQ(askRows[i]), maxQ: maxQ, pricePrecision: depthPrecision, volumePrecision: state.volumePrecision, rowHeight: widget.rowHeight, onTap: widget.onPriceTap != null ? () => widget.onPriceTap!(spotDepthP(askRows[i])) : null, ) : _SpotBookRowPlaceholder( key: ValueKey('ask_ph_$i'), rowHeight: widget.rowHeight, ), Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( state.lastPriceStr != null ? formatRawPrice(state.lastPriceStr!) : formatPrice(state.lastPrice), maxLines: 1, overflow: TextOverflow.clip, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, fontFeatures: const [FontFeature.tabularFigures()], ), ), const SizedBox(width: 4), Text( formatFiatPrice(state.lastPrice), maxLines: 1, overflow: TextOverflow.clip, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 10, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ), ), if (_bookMode != 1) for (var i = 0; i < bidRows.length; i++) spotDepthP(bidRows[i]) > 0 ? _SpotBookRow( key: ValueKey('bid_$i'), isSell: false, price: spotDepthP(bidRows[i]), qty: spotDepthQ(bidRows[i]), maxQ: maxQ, pricePrecision: depthPrecision, volumePrecision: state.volumePrecision, rowHeight: widget.rowHeight, onTap: widget.onPriceTap != null ? () => widget.onPriceTap!(spotDepthP(bidRows[i])) : null, ) : _SpotBookRowPlaceholder( key: ValueKey('bid_ph_$i'), rowHeight: widget.rowHeight, ), // 不用 Spacer,留白留在底部 const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(2), child: Row( children: [ Flexible( flex: (bidRatio * 100).round().clamp(1, 99), child: Container( height: 4, color: AppColors.rise.withAlpha(180), ), ), Flexible( flex: (askRatio * 100).round().clamp(1, 99), child: Container( height: 4, color: AppColors.fall.withAlpha(180), ), ), ], ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${(bidRatio * 100).toStringAsFixed(2)}%', style: const TextStyle(color: AppColors.rise, fontSize: 10), ), Text( '${(askRatio * 100).toStringAsFixed(2)}%', style: const TextStyle(color: AppColors.fall, fontSize: 10), ), ], ), const SizedBox(height: 2), Row( children: [ GestureDetector( onTap: () => _showDepthStepSheet(context), child: Container( height: 20, padding: const EdgeInsets.symmetric(horizontal: 4), alignment: Alignment.center, decoration: BoxDecoration( border: Border.all(color: cs.outline.withAlpha(80)), borderRadius: BorderRadius.circular(4), ), child: Text( _precisionLabel(depthPrecision), style: TextStyle( color: cs.onSurface.withAlpha(180), fontSize: 10, fontWeight: FontWeight.w600, ), ), ), ), const SizedBox(width: 4), const Spacer(), GestureDetector( onTap: () => setState(() => _bookMode = (_bookMode + 1) % 3), child: Container( width: 28, height: 20, padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), decoration: BoxDecoration( border: Border.all(color: cs.outline.withAlpha(80)), borderRadius: BorderRadius.circular(4), ), child: _SpotBookModeIcon(mode: _bookMode), ), ), ], ), ], ), ); } } /// 与合约页订单簿模式图标一致 class _SpotBookModeIcon extends StatelessWidget { const _SpotBookModeIcon({required this.mode}); final int mode; @override Widget build(BuildContext context) { const sellColor = AppColors.fall; const buyColor = AppColors.rise; const emptyColor = Color(0xFFCCCCCC); const lineH = 2.0; const gap = 1.5; Widget line(Color color) => Container( height: lineH, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(1), ), ); final List lines; if (mode == 0) { lines = [ line(sellColor), SizedBox(height: gap), line(sellColor), SizedBox(height: gap), line(buyColor), SizedBox(height: gap), line(buyColor), ]; } else if (mode == 1) { lines = [ line(sellColor), SizedBox(height: gap), line(sellColor), SizedBox(height: gap), line(sellColor), SizedBox(height: gap), line(emptyColor), ]; } else { lines = [ line(emptyColor), SizedBox(height: gap), line(buyColor), SizedBox(height: gap), line(buyColor), SizedBox(height: gap), line(buyColor), ]; } return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: lines, ); } } class _SpotBookRowPlaceholder extends StatelessWidget { const _SpotBookRowPlaceholder({super.key, this.rowHeight = 22.0}); final double rowHeight; @override Widget build(BuildContext context) { return AppShimmer( child: SizedBox( height: rowHeight, child: Row( children: [ Expanded( child: Align( alignment: Alignment.centerLeft, child: shimmerBox(60, 10), ), ), Expanded( child: Align( alignment: Alignment.centerRight, child: shimmerBox(50, 10), ), ), ], ), ), ); } } class _SpotBookRow extends StatelessWidget { const _SpotBookRow({ super.key, required this.isSell, required this.price, required this.qty, required this.maxQ, required this.pricePrecision, required this.volumePrecision, required this.rowHeight, this.onTap, }); final bool isSell; final double price; final double qty; final double maxQ; final int pricePrecision; final int volumePrecision; final double rowHeight; final VoidCallback? onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final color = isSell ? AppColors.fall : AppColors.rise; return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: SizedBox( height: rowHeight, child: LayoutBuilder( builder: (_, c) { final ratio = (qty / (maxQ <= 0 ? 1 : maxQ)).clamp(0.0, 1.0); return Stack( children: [ Positioned( right: 0, top: 1, bottom: 1, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOut, width: c.maxWidth * ratio, decoration: BoxDecoration( color: color.withAlpha(38), borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( price > 0 ? price.toStringAsFixed(pricePrecision) : '--', style: TextStyle( color: color, fontSize: 13, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()], ), ), Text( qty > 0 ? qty.toStringAsFixed(volumePrecision) : '--', style: TextStyle( color: cs.onSurface, fontSize: 13, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ), ], ); }, ), ), ); } } // ══════════════════════════════════════════════════════════════════════ // 底部:当前委托 / 资产 // ══════════════════════════════════════════════════════════════════════ // PageView 本身会 clip,所以 OverflowBox 超出部分不会显示。 class _MeasureSize extends StatefulWidget { const _MeasureSize({required this.child, required this.onSize}); final Widget child; final ValueChanged onSize; @override State<_MeasureSize> createState() => _MeasureSizeState(); } class _MeasureSizeState extends State<_MeasureSize> { final _key = GlobalKey(); double? _last; @override Widget build(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final box = _key.currentContext?.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; final h = box.size.height; if (h != _last) { _last = h; widget.onSize(h); } }); return OverflowBox( alignment: Alignment.topLeft, minHeight: 0, maxHeight: double.infinity, child: KeyedSubtree(key: _key, child: widget.child), ); } } class _SpotBottomSection extends ConsumerStatefulWidget { const _SpotBottomSection({required this.symbol}); final String symbol; @override ConsumerState<_SpotBottomSection> createState() => _SpotBottomSectionState(); } class _SpotBottomSectionState extends ConsumerState<_SpotBottomSection> { late PageController _pageController; bool _programmaticSwitch = false; final _tabHeights = [420.0, 280.0]; // orders, assets static int _tabToIndex(SpotTab tab) => tab == SpotTab.orders ? 0 : 1; static SpotTab _indexToTab(int index) => index == 0 ? SpotTab.orders : SpotTab.assets; @override void initState() { super.initState(); final initial = ref.read(spotProvider(widget.symbol)).activeTab; _pageController = PageController(initialPage: _tabToIndex(initial)); } @override void dispose() { _pageController.dispose(); super.dispose(); } void _onTabTap(SpotTab tab) { _programmaticSwitch = true; ref.read(spotProvider(widget.symbol).notifier).setActiveTab(tab); _pageController.animateToPage( _tabToIndex(tab), duration: const Duration(milliseconds: 280), curve: Curves.easeOut, ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final symbol = widget.symbol; final provider = spotProvider(symbol); final activeTab = ref.watch(provider.select((s) => s.activeTab)); final ordersCount = ref.watch(provider.select((s) => s.displayOrders.length)); final isLoggedIn = ref.watch(isLoggedInProvider); return Container( decoration: BoxDecoration( border: Border(top: BorderSide(color: cs.outline)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ _SpotBottomTab( label: l10n.currentOrdersTab(ordersCount), active: activeTab == SpotTab.orders, onTap: () => _onTabTap(SpotTab.orders), ), const SizedBox(width: 16), _SpotBottomTab( label: l10n.assetsTab, active: activeTab == SpotTab.assets, onTap: () => _onTabTap(SpotTab.assets), ), const Spacer(), AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: activeTab == SpotTab.orders && isLoggedIn ? _OrdersToolbar( key: const ValueKey('orders_toolbar'), symbol: symbol, ) : const SizedBox.shrink(key: ValueKey('no_toolbar')), ), GestureDetector( onTap: () { if (!isLoggedIn) { context.push('/login'); return; } final n = ref.read(spotProvider(symbol).notifier); n.stopPolling(); context.push('/spot/$symbol/history').then((_) { if (context.mounted) n.resumePolling(); }); }, child: Icon(Icons.access_time, color: cs.onSurface.withAlpha(153), size: 18), ), ], ), ), // 滑动内容区 SizedBox( height: _tabHeights.reduce(math.max).clamp(100.0, 2000.0), child: RepaintBoundary( child: PageView( controller: _pageController, physics: const ClampingScrollPhysics(), onPageChanged: (index) { if (_programmaticSwitch) { _programmaticSwitch = false; return; } ref .read(spotProvider(symbol).notifier) .setActiveTab(_indexToTab(index)); }, children: [ _MeasureSize( onSize: (h) { if (_tabHeights[0] != h) setState(() => _tabHeights[0] = h); }, child: _SpotOrdersContent(symbol: symbol), ), _MeasureSize( onSize: (h) { if (_tabHeights[1] != h) setState(() => _tabHeights[1] = h); }, child: _SpotAssetsContent(symbol: symbol), ), ], ), ), ), ], ), ); } } class _OrdersToolbar extends ConsumerWidget { const _OrdersToolbar({super.key, required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final provider = spotProvider(symbol); final notifier = ref.read(provider.notifier); final state = ref.watch(provider); final orders = state.displayOrders; final isLoggedIn = ref.watch(isLoggedInProvider); if (!isLoggedIn || !orders.any((o) => o.isPending)) { return const SizedBox.shrink(); } return GestureDetector( onTap: () async { final err = await notifier.cancelAll(); if (!context.mounted) return; if (err != null) { showTopToast( context, message: _resolveSpotError(err, l10n), backgroundColor: AppColors.fall, ); } else { showTopToast( context, message: l10n.cancelSuccess, backgroundColor: AppColors.rise, ); } }, child: Container( margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: cs.inverseSurface, borderRadius: BorderRadius.circular(4), ), child: Text( l10n.cancelAll, style: TextStyle( color: cs.onInverseSurface, fontSize: 11, fontWeight: FontWeight.w500, ), ), ), ); } } class _SpotOrdersContent extends ConsumerWidget { const _SpotOrdersContent({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final provider = spotProvider(symbol); final notifier = ref.read(provider.notifier); final state = ref.watch(provider); final orders = state.displayOrders; final isLoggedIn = ref.watch(isLoggedInProvider); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: notifier.toggleHideOtherSymbols, child: Row( children: [ _SpotCheckBox(checked: state.hideOtherSymbols), const SizedBox(width: 4), Text(l10n.hideOtherSymbols, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ], ), ), ], ), ), if (!isLoggedIn) _LoginPlaceholder() else if (orders.isEmpty) _EmptyHint(text: l10n.noOpenOrders) else ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: orders.length, itemBuilder: (_, i) => _SpotOrderRow(symbol: symbol, order: orders[i]), ), ], ); } } class _SpotAssetsContent extends ConsumerWidget { const _SpotAssetsContent({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final provider = spotProvider(symbol); final state = ref.watch(provider); final isLoggedIn = ref.watch(isLoggedInProvider); final l10n = AppLocalizations.of(context)!; if (!isLoggedIn) return _LoginPlaceholder(); if (state.wallets.isEmpty) return _EmptyHint(text: l10n.noAssets); return ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: state.wallets.length, itemBuilder: (_, i) => _SpotWalletRow(asset: state.wallets[i]), ); } } class _SpotBottomTab extends StatelessWidget { const _SpotBottomTab({ required this.label, required this.active, required this.onTap, }); final String label; final bool active; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final labelColor = active ? cs.onSurface : cs.onSurface.withAlpha(153); return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: active ? AppColors.brand : Colors.transparent, width: 2, ), ), ), child: Text( label, style: TextStyle( color: labelColor, fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ), ), ); } } class _SpotCheckBox extends StatelessWidget { const _SpotCheckBox({required this.checked}); final bool checked; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Container( width: 14, height: 14, decoration: BoxDecoration( borderRadius: BorderRadius.circular(3), color: checked ? AppColors.brand : Colors.transparent, border: Border.all( color: checked ? AppColors.brand : cs.onSurface.withAlpha(153), width: 1, ), ), child: checked ? const Icon(Icons.check, size: 10, color: Colors.white) : null, ); } } class _SpotOrderRow extends ConsumerWidget { const _SpotOrderRow({required this.symbol, required this.order}); final String symbol; final SpotOrder order; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final notifier = ref.read(spotProvider(symbol).notifier); final pricePre = ref.watch(spotProvider(symbol).select((s) => s.pricePrecision)); final volPre = ref.watch(spotProvider(symbol).select((s) => s.volumePrecision)); final sideColor = order.side == SpotSide.buy ? AppColors.rise : AppColors.fall; final sideLabel = order.side == SpotSide.buy ? l10n.buyAction : l10n.sellAction; final typeLabel = order.type == SpotOrderType.limit ? l10n.limitOrder : l10n.marketOrder; final symDisplay = order.symbol.replaceAll('/', ''); final priceDisplay = order.type == SpotOrderType.market && order.price <= 0 ? l10n.marketPrice : formatAmount(order.price, decimals: pricePre); return Container( padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Theme.of(context).scaffoldBackgroundColor, width: 6, ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( symDisplay, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w700, ), ), const SizedBox(width: 8), _Tag(text: typeLabel, color: cs.onSurface.withAlpha(180)), const SizedBox(width: 4), _Tag(text: sideLabel, color: sideColor, filled: true), const Spacer(), if (order.isPending) GestureDetector( onTap: () async { final err = await notifier.cancelOrder(order); if (!context.mounted) return; if (err != null) { showTopToast(context, message: _resolveSpotError(err, l10n), backgroundColor: AppColors.fall); } else { showTopToast(context, message: l10n.cancelSuccess, backgroundColor: AppColors.rise); } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( border: Border.all(color: cs.outline.withAlpha(80)), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.cancelLabel, style: TextStyle(color: cs.onSurface, fontSize: 11), ), ), ), ], ), const SizedBox(height: 8), _spotDataLine( context, label: '${l10n.orderPriceLabel}(${_baseCoin(order.symbol, quote: true)})', value: priceDisplay, ), const SizedBox(height: 3), _spotDataLine( context, label: '${l10n.tradedDealAmount}(${_baseCoin(order.symbol)})', value: '${formatAmount(order.tradedAmount, decimals: volPre)} / ${formatAmount(order.amount, decimals: volPre)}', ), if (order.createTime != null) ...[ const SizedBox(height: 3), _spotDataLine( context, label: l10n.orderTime, value: DateFormat('yyyy-MM-dd HH:mm:ss') .format(order.createTime!.toLocal()), ), ], ], ), ); } } String _baseCoin(String symbol, {bool quote = false}) { final s = symbol.replaceAll('/', '').toUpperCase(); const quotes = ['USDT', 'USDC', 'BUSD', 'TUSD']; for (final q in quotes) { if (s.endsWith(q) && s.length > q.length) { return quote ? q : s.substring(0, s.length - q.length); } } return quote ? 'USDT' : s; } Widget _spotDataLine( BuildContext context, { required String label, required String value, }) { final cs = Theme.of(context).colorScheme; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11), ), Text( value, style: TextStyle( color: cs.onSurface, fontSize: 12, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ); } class _SpotWalletRow extends ConsumerWidget { const _SpotWalletRow({required this.asset}); final SpotWalletAsset asset; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; // 与「资产 → 现货」完全一致:lookupSpotCoinConfig / spotCoinIconUrl final mapState = ref.watch(spotCoinCacheProvider); final coinCfg = lookupSpotCoinConfig(mapState, asset.coin); final iconUrl = spotCoinIconUrl(mapState, asset.coin); final decimals = coinCfg?.assetDisplayDecimals ?? 2; return Container( padding: const EdgeInsets.fromLTRB(12, 14, 12, 14), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: cs.outline.withAlpha(40), width: 0.6), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 币种图标 + 名称 Row( children: [ CoinIcon( symbol: asset.coin, iconUrl: iconUrl, size: 40, shape: BoxShape.circle, ), const SizedBox(width: 10), Text(asset.coin, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)), ], ), const SizedBox(height: 14), // 三列数据:资产余额 / 可用 / 不可用 Row( children: [ Expanded( child: _WalletCell( label: l10n.assetBalance, value: formatAmount(asset.total, decimals: decimals), align: CrossAxisAlignment.start, ), ), Expanded( child: _WalletCell( label: l10n.availableLabel, value: formatAmount(asset.balance, decimals: decimals), align: CrossAxisAlignment.center, ), ), Expanded( child: _WalletCell( label: l10n.unavailableLabel, value: formatAmount(asset.frozenBalance, decimals: decimals), align: CrossAxisAlignment.end, ), ), ], ), ], ), ); } } class _WalletCell extends StatelessWidget { const _WalletCell( {required this.label, required this.value, required this.align}); final String label; final String value; final CrossAxisAlignment align; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final textAlign = align == CrossAxisAlignment.start ? TextAlign.left : align == CrossAxisAlignment.end ? TextAlign.right : TextAlign.center; return Column( crossAxisAlignment: align, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 11), textAlign: textAlign), const SizedBox(height: 4), Text(value, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], ), textAlign: textAlign), ], ); } } class _Tag extends StatelessWidget { const _Tag({required this.text, required this.color, this.filled = false}); final String text; final Color color; final bool filled; @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: filled ? color.withAlpha(isDark ? 55 : 35) : Colors.transparent, border: Border.all(color: color.withAlpha(160), width: 0.5), borderRadius: BorderRadius.circular(3), ), child: Text( text, style: TextStyle( color: color, fontSize: 9, fontWeight: FontWeight.w500, ), ), ); } } class _EmptyHint extends StatelessWidget { const _EmptyHint({required this.text}); final String text; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.symmetric(vertical: 36), alignment: Alignment.center, child: Text(text, style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13)), ); } } class _LoginPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return Container( padding: const EdgeInsets.symmetric(vertical: 36), alignment: Alignment.center, child: Column( children: [ Text(l10n.loginPrompt, style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13)), const SizedBox(height: 10), OutlinedButton( onPressed: () => context.push('/login'), child: Text(l10n.loginText), ), ], ), ); } } // ══════════════════════════════════════════════════════════════════════ // 输入精度限制 // ══════════════════════════════════════════════════════════════════════ class _PrecisionInputFormatter extends TextInputFormatter { _PrecisionInputFormatter(this.decimalRange); final int decimalRange; @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue) { final t = newValue.text; if (t.isEmpty) return newValue; if (decimalRange <= 0) { // 整数:保留数字 if (RegExp(r'^[0-9]+$').hasMatch(t)) return newValue; return oldValue; } final pattern = RegExp(r'^\d*\.?\d{0,' + decimalRange.toString() + r'}$'); if (pattern.hasMatch(t)) return newValue; return oldValue; } } // ══════════════════════════════════════════════════════════════════════ // 骨架屏 // ══════════════════════════════════════════════════════════════════════ class _SpotShimmer extends StatelessWidget { const _SpotShimmer(); @override Widget build(BuildContext context) { return AppShimmer( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( children: [ // 上半:左侧下单区 + 右侧盘口 SizedBox( height: 400, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 左侧下单区骨架 Expanded( flex: 55, child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 买/卖 tab Row(children: [ shimmerBox(70, 30, radius: 6), const SizedBox(width: 8), shimmerBox(70, 30, radius: 6), ]), const SizedBox(height: 12), // 价格输入框 shimmerFill(44, radius: 8), const SizedBox(height: 10), // 数量输入框 shimmerFill(44, radius: 8), const SizedBox(height: 10), // 滑块 shimmerFill(20, radius: 10), const SizedBox(height: 16), // 下单按钮 shimmerFill(44, radius: 8), const SizedBox(height: 16), // 可用/可买数据行 ...List.generate( 3, (_) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ shimmerBox(60, 11), shimmerBox(70, 11), ], ), )), ], ), ), ), // 右侧盘口骨架 Expanded( flex: 45, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12), child: Column( children: [ // 盘口 header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ shimmerBox(40, 11), shimmerBox(60, 11), ], ), const SizedBox(height: 8), // 盘口行 ...List.generate( 12, (_) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Expanded( child: shimmerBox( double.infinity, 11)), const SizedBox(width: 6), Expanded( child: shimmerBox( double.infinity, 11)), ], ), )), ], ), ), ), ], ), ), // 下半:委托/资产区 Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // tab 行 Row(children: [ shimmerBox(80, 28, radius: 6), const SizedBox(width: 8), shimmerBox(60, 28, radius: 6), ]), const SizedBox(height: 16), Center(child: shimmerBox(120, 14)), ], ), ), ], ), ), ); } } class _SizeReporter extends StatefulWidget { const _SizeReporter({required this.child, required this.onHeight}); final Widget child; final ValueChanged onHeight; @override State<_SizeReporter> createState() => _SizeReporterState(); } class _SizeReporterState extends State<_SizeReporter> { final _key = GlobalKey(); void _report() { final ctx = _key.currentContext; if (ctx == null) return; final box = ctx.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; widget.onHeight(box.size.height); } @override Widget build(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) => _report()); return KeyedSubtree(key: _key, child: widget.child); } } // ══════════════════════════════════════════════════════════════════════ // 错误码 → 本地化 // ══════════════════════════════════════════════════════════════════════ String _resolveSpotError(String err, AppLocalizations l10n) { switch (err) { case 'errEnterPrice': return l10n.enterPrice; case 'errEnterAmount': return l10n.errEnterAmount; case 'errEnterTriggerPrice': return l10n.enterTriggerPrice; case 'errInvalidOrderId': return l10n.errInvalidOrderId; case 'errVolumeInsufficient': return l10n.errVolumeInsufficient; case 'errNoOrdersToCancel': return l10n.errNoOrdersToCancel; case 'errTimeout': return l10n.errTimeout; case 'errNetworkError': return l10n.errNetworkError; case 'errConditionalNotSupported': return l10n.spotConditionalNotSupported; default: return err; } }