import 'dart:async'; import 'dart:math' show max, pow; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:gal/gal.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'dart:io'; import 'package:qr_flutter/qr_flutter.dart'; import '../../../core/config/app_config.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/network/dio_client.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/number_format.dart'; import '../../../core/utils/symbol_display.dart'; import '../../../core/utils/top_toast.dart'; import '../../../data/services/auth_service.dart'; import '../../../providers/auth_provider.dart'; import '../../../providers/copy_trading_provider.dart'; import '../../../providers/futures_provider.dart'; import '../../../providers/spot_provider.dart'; import '../../widgets/common/app_refresh_indicator.dart'; import '../../widgets/common/app_shimmer.dart'; import '../../widgets/common/kline_toolbar_icon.dart'; import '../../widgets/common/symbol_picker_sheet.dart'; /// 将仓位 marginMode 字符串(来自后端)映射为 l10n 标签 String _marginModeLabel(String mode, AppLocalizations l10n) { switch (mode) { case '分仓': return l10n.splitMargin; default: return l10n.crossMargin; } } /// marginMode → 标签颜色:全仓→蓝,分仓/逐仓→紫 Color _marginModeColor(String mode) { switch (mode) { case '分仓': case '逐仓': return AppColors.rankPurple; default: return AppColors.tagBlue; } } /// 从合约 symbol 提取基础币种名(BTC/USDT → BTC,ETCUSDT → ETC) String _baseCoin(String sym) { if (sym.contains('/')) return sym.split('/').first; return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); } /// 未登录时跳转登录页,返回 false 表示操作被拦截 bool _requireLogin(BuildContext context, WidgetRef ref) { final isLoggedIn = ref.read(isLoggedInProvider); if (!isLoggedIn) { context.push('/login'); return false; } return true; } /// 对齐原型的确认弹窗:上方消息 + 可选红色副文本,下方取消/确定按钮 Future _showFuturesConfirm( BuildContext context, { required String message, String? subMessage, String? cancelLabel, String? confirmLabel, }) async { final l10n = AppLocalizations.of(context)!; final cancel = cancelLabel ?? l10n.cancelLabel; final confirm = confirmLabel ?? l10n.confirmLabel; final result = await showDialog( context: context, barrierDismissible: true, builder: (ctx) { final cs = Theme.of(ctx).colorScheme; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), backgroundColor: cs.surface, insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( message, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600), ), if (subMessage != null) ...[ const SizedBox(height: 8), Text( subMessage, style: const TextStyle( color: AppColors.fall, fontSize: 13, height: 1.5), ), ], ], ), ), Divider(height: 1, thickness: 0.5, color: cs.outline.withAlpha(80)), IntrinsicHeight( child: Row( children: [ Expanded( child: TextButton( onPressed: () => Navigator.of(ctx).pop(false), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( bottomLeft: Radius.circular(16)), ), ), child: Text( cancel, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 15), ), ), ), VerticalDivider( width: 1, thickness: 0.5, color: cs.outline.withAlpha(80)), Expanded( child: TextButton( onPressed: () => Navigator.of(ctx).pop(true), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( bottomRight: Radius.circular(16)), ), ), child: Text( confirm, style: const TextStyle( color: AppColors.brand, fontSize: 15, fontWeight: FontWeight.w700), ), ), ), ], ), ), ], ), ); }, ); return result == true; } class FuturesScreen extends ConsumerStatefulWidget { const FuturesScreen({ super.key, required this.symbol, this.showSpotSwitcher = true, }); final String symbol; final bool showSpotSwitcher; @override ConsumerState createState() => _FuturesScreenState(); } class _FuturesScreenState extends ConsumerState { late final ScrollController _scroll; int _obRowCount = 7; double _obRowH = 22.0; // 订单薄每行高度(动态计算使左右精确对齐) double _leftPanelHeight = 490.0; // 左侧面板实测高度 final _orderPanelKey = GlobalKey<_OrderPanelState>(); void _onLeftPanelHeight(double h) { // countFixedH=150:保守余量,确保 N 行内容不溢出 // rowH = (h - 150) / (2N):让行高略小于最大值,Spacer 吸收余量 const countFixedH = 150.0; final n = ((h - countFixedH) / 44).floor().clamp(4, 14); final rh = ((h - 150.0) / (n * 2)).clamp(18.0, 28.0); if (n != _obRowCount || (rh - _obRowH).abs() > 0.1 || h != _leftPanelHeight) { setState(() { _obRowCount = n; _obRowH = rh; _leftPanelHeight = h; }); } } @override void initState() { super.initState(); _scroll = ScrollController(); _scroll.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(futuresActiveSymbolProvider.notifier).state = widget.symbol; if (widget.showSpotSwitcher) { ref.read(lastTradingRouteProvider.notifier).state = '/futures/${widget.symbol}'; } }); } @override void didUpdateWidget(FuturesScreen oldWidget) { super.didUpdateWidget(oldWidget); if (widget.symbol != oldWidget.symbol) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(futuresActiveSymbolProvider.notifier).state = widget.symbol; }); } } @override void dispose() { _scroll.removeListener(_onScroll); _scroll.dispose(); super.dispose(); } Future _pushAndPausePolling(BuildContext context, String path) async { final notifier = ref.read(futuresProvider(widget.symbol).notifier); notifier.stopPolling(); await context.push(path); if (mounted) notifier.resumePolling(widget.symbol); } void _onScroll() { if (_scroll.position.pixels < _scroll.position.maxScrollExtent - 200) return; final s = ref.read(futuresProvider(widget.symbol)); if (s.activeTab == FuturesTab.orders) { ref.read(futuresProvider(widget.symbol).notifier).loadMoreOrders(); } } @override Widget build(BuildContext context) { final symbol = widget.symbol; final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( elevation: 0, toolbarHeight: 44, titleSpacing: 16, title: widget.showSpotSwitcher ? _SpotFuturesTabHeader( activeIndex: 1, onTap: (i) { if (i == 0) { final spotSym = ref.read(spotActiveSymbolProvider); context.go('/spot/${spotSym.isNotEmpty ? spotSym : 'BTCUSDT'}'); } }, ) : Text( l10n.perpetualContract, style: TextStyle( color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600, ), ), centerTitle: false, bottom: PreferredSize( preferredSize: const Size.fromHeight(1), child: Container(height: 1, color: cs.outline.withAlpha(40)), ), actions: [ // K 线图标,点击进入行情详情页 IconButton( icon: KlineToolbarIcon(color: cs.onSurface.withAlpha(180)), onPressed: () => _pushAndPausePolling(context, '/market/futures/$symbol'), padding: const EdgeInsets.symmetric(horizontal: 8), constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), ], ), body: Listener( onPointerDown: (_) => FocusScope.of(context).unfocus(), child: Builder(builder: (context) { final isLoading = ref.watch(futuresProvider(symbol).select((s) => s.isLoading)); if (isLoading) return const _FuturesShimmer(); return AppRefreshIndicator( onRefresh: () => ref.read(futuresProvider(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: _OrderPanel( key: _orderPanelKey, symbol: symbol, showSpotSwitcher: widget.showSpotSwitcher, ), ), ), ), Expanded( flex: 45, child: SizedBox( height: _leftPanelHeight, child: RepaintBoundary( child: _OrderBookPanel( symbol: symbol, rowCount: _obRowCount, rowHeight: _obRowH, onPriceTap: (price) => _orderPanelKey.currentState ?.setBookPrice(price), ), ), ), ), ], ), const SizedBox(height: 8), _BottomSection(symbol: symbol), const SizedBox(height: 16), ], ), ), ); // RefreshIndicator }), // Builder ), ); } } class _OrderPanel extends ConsumerStatefulWidget { const _OrderPanel({ super.key, required this.symbol, required this.showSpotSwitcher, }); final String symbol; final bool showSpotSwitcher; @override ConsumerState<_OrderPanel> createState() => _OrderPanelState(); } class _OrderPanelState extends ConsumerState<_OrderPanel> { final _priceController = TextEditingController(); final _amountController = TextEditingController(); final _triggerPriceController = TextEditingController(); final _tpController = TextEditingController(); final _slController = TextEditingController(); bool _updatingFromSlider = false; bool _priceFilled = false; // 是否已完成首次价格回显 double _lastPrice = 0; // 最新价缓存,用 ref.listen 维护,不触发 rebuild // 可开多/开空最大值快照 String _maxOpenAmt = '--'; // 市价单:每 5 秒自动刷新最大可开量 Timer? _marketRefreshTimer; // 监听路由 secondaryAnimation,导航返回时清除焦点 Animation? _secondaryAnim; bool _isCoveredByRoute = false; @override void initState() { super.initState(); _amountController.addListener(_onAmountChanged); // 进入页面后首帧快照最大可开量,并根据当前下单类型决定是否启动定时器 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _refreshMaxAmount(); _updateMarketTimer(); }); // 价格输入框变化时(非市价单)刷新最大可开量 _priceController.addListener(_onPriceChanged); _triggerPriceController.addListener(_onPriceChanged); } void _onPriceChanged() { final s = ref.read(futuresProvider(widget.symbol)); if (s.orderType != OrderType.market) _refreshMaxAmount(); } void _refreshMaxAmount() { if (!mounted) return; final s = ref.read(futuresProvider(widget.symbol)); setState(() { _maxOpenAmt = _calcMaxOpenAmount(s); }); } /// 市价单开启 5 秒定时刷新,其他类型取消定时器(由价格输入框变化驱动) void _updateMarketTimer() { final s = ref.read(futuresProvider(widget.symbol)); if (s.orderType == OrderType.market) { _marketRefreshTimer ??= Timer.periodic( const Duration(seconds: 5), (_) => _refreshMaxAmount()); } else { _marketRefreshTimer?.cancel(); _marketRefreshTimer = null; } } @override void didChangeDependencies() { super.didChangeDependencies(); final anim = ModalRoute.of(context)?.secondaryAnimation; if (anim != _secondaryAnim) { _secondaryAnim?.removeStatusListener(_onSecondaryAnimation); _secondaryAnim = anim; _secondaryAnim?.addStatusListener(_onSecondaryAnimation); } } void _onSecondaryAnimation(AnimationStatus status) { if (status == AnimationStatus.forward || status == AnimationStatus.completed) { _isCoveredByRoute = true; } else if (status == AnimationStatus.dismissed && _isCoveredByRoute) { _isCoveredByRoute = false; // postFrameCallback 确保在 Flutter 自动恢复焦点之后再清除 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) FocusManager.instance.primaryFocus?.unfocus(); }); } } @override void didUpdateWidget(_OrderPanel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.symbol != widget.symbol) { // 切换币对:延迟到下一帧再清空,重置首次价格回显标记 _priceFilled = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _priceController.clear(); _amountController.clear(); _triggerPriceController.clear(); _tpController.clear(); _slController.clear(); }); } } void _onAmountChanged() { if (_updatingFromSlider) return; final state = ref.read(futuresProvider(widget.symbol)); final amount = double.tryParse(_amountController.text.replaceAll(',', '')); final notifier = ref.read(futuresProvider(widget.symbol).notifier); if (amount == null || amount <= 0) { notifier.setSliderPercentFromInput(0); return; } final refPrice = _refPrice(state); // 限价/计划委托未填价格时,滑块归零但不弹 toast(toast 在点下单时才提示) if (refPrice <= 0) { notifier.setSliderPercentFromInput(0); return; } final contractSize = state.contractSize > 0 ? state.contractSize : 1.0; // ── 平仓模式:以仓位可平量(BTC)为 max ── if (state.positionMode == PositionMode.close) { final maxBtc = _closeMaxBtc(state); if (maxBtc <= 0) return; final double pct; switch (state.amountUnit) { case AmountUnit.btc: pct = (amount / maxBtc).clamp(0.0, 1.0); case AmountUnit.usdt: final maxUsdt = maxBtc * refPrice; pct = maxUsdt > 0 ? (amount / maxUsdt).clamp(0.0, 1.0) : 0.0; case AmountUnit.lots: final maxLots = contractSize > 0 && refPrice > 0 ? maxBtc * refPrice / contractSize : 0.0; pct = maxLots > 0 ? (amount / maxLots).clamp(0.0, 1.0) : 0.0; } notifier.setSliderPercentFromInput(pct); return; } // ── 开仓模式:以可用保证金为 max ── final availableMargin = state.accountInfo.availableMargin; final leverage = state.leverage; if (availableMargin <= 0 || refPrice <= 0) return; // maxNotional = 最大名义仓位价值(保证金 × 杠杆) final maxNotional = availableMargin * leverage; if (maxNotional <= 0) return; final double pct; switch (state.amountUnit) { case AmountUnit.lots: if (contractSize <= 0) return; final maxL = (maxNotional / contractSize).floor(); pct = maxL > 0 ? (amount / maxL).clamp(0.0, 1.0) : 0.0; case AmountUnit.usdt: pct = (amount / maxNotional).clamp(0.0, 1.0); case AmountUnit.btc: pct = (amount * refPrice / maxNotional).clamp(0.0, 1.0); } notifier.setSliderPercentFromInput(pct); } /// 计算数量上限时使用的参考价格。 /// 返回 0 表示必填价格未填,调用方应提示用户先输入价格。 /// 市价 → 最新成交价 /// 限价 / 计划限价 → 价格输入框(未填返回 0) /// 计划市价 → 触发价输入框(未填返回 0) double _refPrice(FuturesState state) { switch (state.orderType) { case OrderType.market: return state.lastPrice; case OrderType.limit: case OrderType.conditionalLimit: final v = double.tryParse(_priceController.text.replaceAll(',', '')); return (v != null && v > 0) ? v : 0.0; case OrderType.conditionalMarket: final v = double.tryParse(_triggerPriceController.text.replaceAll(',', '')); return (v != null && v > 0) ? v : 0.0; } } /// 检查参考价格是否有效,无效时弹出提示并返回 false bool _checkRefPrice(BuildContext context, FuturesState state) { if (_refPrice(state) > 0) return true; final l10n = AppLocalizations.of(context)!; final hint = state.orderType == OrderType.conditionalMarket ? l10n.enterTriggerPrice : l10n.enterPrice; showTopToast(context, message: hint, backgroundColor: AppColors.fall); return false; } /// 当前符合 symbol 的最大可平量(单位:BTC/基础币),取多空中较大的一方 double _closeMaxBtc(FuturesState state) { final normSym = state.symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase(); return state.positions .where((p) => p.symbol.replaceAll('/', '').toUpperCase() == normSym) .fold(0.0, (m, p) => p.availableSize > m ? p.availableSize : m); } @override void dispose() { _secondaryAnim?.removeStatusListener(_onSecondaryAnimation); _amountController.removeListener(_onAmountChanged); _priceController.removeListener(_onPriceChanged); _triggerPriceController.removeListener(_onPriceChanged); _marketRefreshTimer?.cancel(); _priceController.dispose(); _amountController.dispose(); _triggerPriceController.dispose(); _tpController.dispose(); _slController.dispose(); super.dispose(); } /// 订单薄点击回调:将价格填入对应输入框 /// 计划市价 → 回显到触发价格;其余情况(含计划限价)→ 回显到委托价格 void setBookPrice(double price) { if (!mounted) return; final state = ref.read(futuresProvider(widget.symbol)); final precision = state.pricePrecision; final formatted = price.toStringAsFixed(precision); if (state.orderType == OrderType.conditionalMarket) { _triggerPriceController.text = formatted; } else { _priceController.text = formatted; } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final provider = futuresProvider(widget.symbol); final notifier = ref.read(provider.notifier); // 切换开仓/平仓模式时,清空数量输入框和滑块 ref.listen(provider.select((s) => s.positionMode), (prev, next) { if (prev != next) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _amountController.clear(); _priceController.clear(); notifier.setSliderPercent(0); }); } }); // 切换下单类型时,清空所有输入框和滑块; // 重置 _priceFilled,限价/计划限价会在下一帧自动回显最新价; // 同时重新快照最大可开量 ref.listen(provider.select((s) => s.orderType), (prev, next) { if (prev != next) { _priceFilled = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; FocusManager.instance.primaryFocus?.unfocus(); _priceController.clear(); _amountController.clear(); _triggerPriceController.clear(); _tpController.clear(); _slController.clear(); notifier.setSliderPercent(0); _refreshMaxAmount(); _updateMarketTimer(); }); } }); // 账户数据加载完成时刷新最大可开量(进入页面时账户信息可能尚未到达) ref.listen(provider.select((s) => s.accountInfo.availableMargin), (prev, next) { if (prev != next) _refreshMaxAmount(); }); // 最新价:仅缓存到字段,不触发 rebuild(消除每 ~120ms 一次的整面板重建) // _priceFilled 逻辑也移到此处 ref.listen(provider.select((s) => s.lastPrice), (_, next) { _lastPrice = next; if (!_priceFilled && next > 0) { _priceFilled = true; final precision = ref.read(provider).pricePrecision; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_priceController.text.isEmpty) { _priceController.text = next.toStringAsFixed(precision); } }); } }); // 首次 build 时同步一次(ref.listen 不回调当前值) if (_lastPrice == 0) { _lastPrice = ref.read(provider).lastPrice; if (!_priceFilled && _lastPrice > 0) { _priceFilled = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_priceController.text.isEmpty) { _priceController.text = _lastPrice.toStringAsFixed(ref.read(provider).pricePrecision); } }); } } final change24h = ref.watch(provider.select((s) => s.change24h)); final isConditionalOrder = ref.watch(provider.select((s) => s.isConditionalOrder)); final showPriceInput = ref.watch(provider.select((s) => s.showPriceInput)); final isMarketOrder = ref.watch(provider.select((s) => s.orderType == OrderType.market)); final amountUnit = ref.watch(provider.select((s) => s.amountUnit)); final amountUnitLabelRaw = ref.watch(provider.select((s) => s.amountUnitLabel)); final sliderPercent = ref.watch(provider.select((s) => s.sliderPercent)); final tpslEnabled = ref.watch(provider.select((s) => s.tpslEnabled)); final leverage = ref.watch(provider.select((s) => s.leverage)); final availableMargin = ref.watch(provider.select((s) => s.accountInfo.availableMargin)); final positionMode = ref.watch(provider.select((s) => s.positionMode)); final isClose = positionMode == PositionMode.close; final coinPrecision = ref.watch(provider.select((s) => s.coinPrecision)); final normSym = widget.symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase(); final longAvail = ref.watch(provider.select((s) => s.positions .where((p) => p.symbol.replaceAll('/', '').toUpperCase() == normSym && p.side == OrderSide.long) .fold(0.0, (v, p) => v + p.availableSize))); final shortAvail = ref.watch(provider.select((s) => s.positions .where((p) => p.symbol.replaceAll('/', '').toUpperCase() == normSym && p.side == OrderSide.short) .fold(0.0, (v, p) => v + p.availableSize))); final coinSymbol = _baseCoin(widget.symbol); final pricePrecision = ref.watch(provider.select((s) => s.pricePrecision)); final amountPrecision = ref.watch(provider.select((s) => s.currentAmountPrecision)); final priceFormatter = _PrecisionInputFormatter(pricePrecision); final amountFormatter = _PrecisionInputFormatter(amountPrecision); final isLoggedIn = ref.watch(isLoggedInProvider); final isSwitchingMode = ref.watch(provider.select((s) => s.isSwitchingMode)); final marginMode = ref.watch(provider.select((s) => s.marginMode)); // ── 高频复用样式(每次 rebuild 计算一次,复用 N 次)───────── final labelStyle = TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11); final valueStyle = TextStyle( color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Symbol row(v2:在左侧表单内)────────────── 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(widget.symbol), 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(change24h), style: TextStyle( color: AppColors.changeColor(change24h), fontSize: 12, fontWeight: FontWeight.w500, fontFeatures: const [FontFeature.tabularFigures()]), ), ], ), ), ), const SizedBox(height: 8), _MarginLeverageRow(symbol: widget.symbol), const SizedBox(height: 10), // TODO: 平仓 tab 功能暂时隐藏,待接口完善后恢复 // _PositionModePills(symbol: widget.symbol), // const SizedBox(height: 10), _OrderTypeDropdown(symbol: widget.symbol), const SizedBox(height: 8), if (isConditionalOrder) ...[ _LargeInput( controller: _triggerPriceController, label: AppLocalizations.of(context)!.triggerPrice, unit: 'USDT', suffixDropdown: AppLocalizations.of(context)!.markLabel, inputFormatters: [priceFormatter], ), const SizedBox(height: 8), ], if (isMarketOrder) ...[ // 市价委托:显示只读"市价"占位框 Builder(builder: (context) { final cs = Theme.of(context).colorScheme; return Container( height: 52, decoration: BoxDecoration( color: cs.onSurface.withAlpha(8), borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Expanded( child: Text( AppLocalizations.of(context)!.marketPrice, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w500, ), ), ), Text( 'USDT', style: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 13, ), ), ], ), ); }), const SizedBox(height: 8), ] else if (showPriceInput) ...[ _LargeInput( controller: _priceController, label: AppLocalizations.of(context)!.priceLabel2, unit: 'USDT', inputFormatters: [priceFormatter], ), const SizedBox(height: 8), ], // ── 数量输入 ── _LargeInput( key: const ValueKey('amount_input'), controller: _amountController, label: AppLocalizations.of(context)!.quantityLabel, unit: amountUnit == AmountUnit.lots ? AppLocalizations.of(context)!.lotsLabel : amountUnitLabelRaw, showUnitDropdown: true, onUnitTap: () => _showAmountUnitSheet(context, notifier), inputFormatters: [amountFormatter], ), const SizedBox(height: 10), // ── 百分比滑动条 ── _PercentSlider( percent: sliderPercent, enabled: true, onChanged: (pct) { // 始终从 ref.read 取最新状态,避免闭包捕获旧值导致联动失效 final s = ref.read(futuresProvider(widget.symbol)); // 限价/计划委托未填价格时拦截,提示用户 if (pct > 0 && !_checkRefPrice(context, s)) return; notifier.setSliderPercent(pct); _updatingFromSlider = true; if (pct == 0) { _amountController.clear(); } else { final contractSize = s.contractSize > 0 ? s.contractSize : 1.0; final curPrice = _refPrice(s); // ── 平仓模式:以仓位可平量(BTC)为基准 ── if (s.positionMode == PositionMode.close) { final maxBtc = _closeMaxBtc(s); if (maxBtc > 0) { switch (s.amountUnit) { case AmountUnit.btc: final rawBtcClose = maxBtc * pct; final btcCloseFactor = pow(10, s.coinPrecision).toDouble(); final truncBtcClose = (rawBtcClose * btcCloseFactor).truncateToDouble() / btcCloseFactor; _amountController.text = truncBtcClose.toStringAsFixed(s.coinPrecision); case AmountUnit.usdt: final rawCloseUsdt = maxBtc * curPrice * pct; final closeDisplayDp = s.usdtPrecision < 2 ? s.usdtPrecision : 2; final closeFactor = pow(10, closeDisplayDp).toDouble(); final flooredCloseUsdt = (rawCloseUsdt * closeFactor).floor() / closeFactor; _amountController.text = flooredCloseUsdt.toStringAsFixed(closeDisplayDp); case AmountUnit.lots: if (contractSize > 0 && curPrice > 0) { final maxLots = maxBtc * curPrice / contractSize; final lots = maxLots * pct; _amountController.text = s.volScale > 0 ? lots.toStringAsFixed(s.volScale) : lots.floor().toString(); } } } } else { // ── 开仓模式:以可用保证金为基准 ── final curMargin = s.accountInfo.availableMargin; final curLeverage = s.leverage; if (curMargin > 0 && curPrice > 0) { final feeMultiplier = 1 + s.openFeeRate * curLeverage; final effectiveMargin = curMargin / feeMultiplier * pct; final maxNotional = effectiveMargin * curLeverage; switch (s.amountUnit) { case AmountUnit.usdt: final displayDp = s.usdtPrecision < 2 ? s.usdtPrecision : 2; final factor = pow(10, displayDp).toDouble(); final flooredUsdt = (maxNotional * factor).floor() / factor; _amountController.text = flooredUsdt.toStringAsFixed(displayDp); case AmountUnit.btc: final rawBtcOpen = maxNotional / curPrice; final btcOpenFactor = pow(10, s.coinPrecision).toDouble(); final truncBtcOpen = (rawBtcOpen * btcOpenFactor).truncateToDouble() / btcOpenFactor; _amountController.text = truncBtcOpen.toStringAsFixed(s.coinPrecision); case AmountUnit.lots: if (contractSize <= 0) break; final maxL = (effectiveMargin * curLeverage / contractSize) .floor(); _amountController.text = s.volScale > 0 ? maxL.toStringAsFixed(s.volScale) : maxL.toString(); } } } } _updatingFromSlider = false; }, ), ...[ const SizedBox(height: 10), Row( children: [ Text('${AppLocalizations.of(context)!.availableLabel} ', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), Text( '${formatAmount(availableMargin)} USDT', style: TextStyle( color: cs.onSurface, fontSize: 12, fontWeight: FontWeight.w600), ), const Spacer(), GestureDetector( onTap: () async { final notifier = ref.read(futuresProvider(widget.symbol).notifier); notifier.stopPolling(); await context.push('/asset/transfer'); if (context.mounted) notifier.resumePolling(widget.symbol); }, child: Icon(Icons.swap_horiz, color: cs.onSurface.withAlpha(153), size: 16), ), ], ), const SizedBox(height: 6), ], // 可开多 + 保证金(开多按钮上方) if (isLoggedIn && !isClose) ...[ Row(children: [ Text('${AppLocalizations.of(context)!.canOpenLong} ', style: labelStyle), Flexible( child: Text('$_maxOpenAmt $amountUnitLabelRaw', style: valueStyle, overflow: TextOverflow.ellipsis)), ]), const SizedBox(height: 2), ListenableBuilder( listenable: Listenable.merge([ _priceController, _amountController, _triggerPriceController ]), builder: (context, _) { final marginStr = _calcMargin(_lastPrice, leverage); return Text( AppLocalizations.of(context)!.marginBalance(marginStr), style: labelStyle); }, ), const SizedBox(height: 6), ], SizedBox( width: double.infinity, height: 40, child: ElevatedButton( onPressed: isSwitchingMode ? null : isLoggedIn ? () => isClose ? _closeOrder(context, notifier, OrderSide.long) : _placeOrder(context, notifier, OrderSide.long) : () => context.push('/login'), style: ElevatedButton.styleFrom( backgroundColor: isClose ? AppColors.fall : AppColors.rise, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: Text( isClose ? AppLocalizations.of(context)!.closeLong : AppLocalizations.of(context)!.openLong, style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), ), ), ), const SizedBox(height: 8), // 可开空 + 保证金(开空按钮上方) if (isLoggedIn && !isClose) ...[ Row(children: [ Text('${AppLocalizations.of(context)!.canOpenShort} ', style: labelStyle), Flexible( child: Text('$_maxOpenAmt $amountUnitLabelRaw', style: valueStyle, overflow: TextOverflow.ellipsis)), ]), const SizedBox(height: 2), ListenableBuilder( listenable: Listenable.merge([ _priceController, _amountController, _triggerPriceController ]), builder: (context, _) { final marginStr = _calcMargin(_lastPrice, leverage); return Text( AppLocalizations.of(context)!.marginBalance(marginStr), style: labelStyle); }, ), const SizedBox(height: 6), ], SizedBox( width: double.infinity, height: 40, child: ElevatedButton( onPressed: isSwitchingMode ? null : isLoggedIn ? () => isClose ? _closeOrder(context, notifier, OrderSide.short) : _placeOrder(context, notifier, OrderSide.short) : () => context.push('/login'), style: ElevatedButton.styleFrom( backgroundColor: isClose ? AppColors.rise : AppColors.fall, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: Text( isClose ? AppLocalizations.of(context)!.closeShort : AppLocalizations.of(context)!.openShort, style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600), ), ), ), const SizedBox(height: 12), ], ), ); } Future _placeOrder( BuildContext context, FuturesNotifier notifier, OrderSide side) async { if (!_requireLogin(context, ref)) return; // formatPrice 会加千分符,解析时需先去掉逗号 final price = double.tryParse(_priceController.text.replaceAll(',', '')); final amount = double.tryParse(_amountController.text.replaceAll(',', '')); final triggerPrice = double.tryParse(_triggerPriceController.text.replaceAll(',', '')); final tpPrice = double.tryParse(_tpController.text.replaceAll(',', '')); final slPrice = double.tryParse(_slController.text.replaceAll(',', '')); final err = await notifier.placeOpenOrder( side: side, entrustPrice: price, triggerPrice: triggerPrice, volume: amount, tpPrice: tpPrice, slPrice: slPrice, ); if (!context.mounted) return; if (err == null) { // 下单成功后清空输入框 _priceController.clear(); _amountController.clear(); _triggerPriceController.clear(); _tpController.clear(); _slController.clear(); notifier.setSliderPercent(0); } final l10n0 = AppLocalizations.of(context)!; showTopToast(context, message: err != null ? (resolveProviderError(err, l10n0) ?? err) : l10n0.orderSuccess, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } Future _closeOrder( BuildContext context, FuturesNotifier notifier, OrderSide side) async { if (!_requireLogin(context, ref)) return; final price = double.tryParse(_priceController.text.replaceAll(',', '')); final triggerPrice = double.tryParse(_triggerPriceController.text.replaceAll(',', '')); // 将用户输入量转为基础币(BTC)后传入;0/null 表示全仓平 final inputAmount = double.tryParse(_amountController.text.replaceAll(',', '')); final s = ref.read(futuresProvider(widget.symbol)); final double? volumeInBtc; if (inputAmount != null && inputAmount > 0) { final cs = s.contractSize > 0 ? s.contractSize : 1.0; final isMarketClose = s.orderType == OrderType.market; final ep = (!isMarketClose && price != null && price > 0) ? price : s.lastPrice; volumeInBtc = switch (s.amountUnit) { AmountUnit.btc => inputAmount, AmountUnit.usdt => ep > 0 ? inputAmount / ep : null, AmountUnit.lots => ep > 0 ? inputAmount * cs / ep : null, }; } else { volumeInBtc = null; // 全仓平 } final isConditional = s.orderType == OrderType.conditionalMarket || s.orderType == OrderType.conditionalLimit; String? err; if (isConditional && triggerPrice != null && triggerPrice > 0) { err = await notifier.closeConditionalByDirection( side, triggerPrice: triggerPrice, volume: volumeInBtc, entrustPrice: (price != null && price > 0) ? price : 0, ); } else { final isMarket = s.orderType == OrderType.market; err = await notifier.closeByDirection( side, price: isMarket ? null : price, volume: volumeInBtc, ); } if (!context.mounted) return; if (err == null) { _amountController.clear(); _triggerPriceController.clear(); notifier.setSliderPercent(0); } final l10n1 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n1) ?? l10n1.closeOrderSubmitted, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } String _calcMargin(double lastPrice, double leverage) { final state = ref.read(futuresProvider(widget.symbol)); // 计划市价用触发价,计划限价/限价用价格输入框,市价用最新价 final double price; if (state.orderType == OrderType.conditionalMarket) { price = double.tryParse(_triggerPriceController.text.replaceAll(',', '')) ?? lastPrice; } else { price = double.tryParse(_priceController.text.replaceAll(',', '')) ?? lastPrice; } final amount = double.tryParse(_amountController.text.replaceAll(',', '')) ?? 0; if (amount == 0 || leverage == 0) return '0.00'; final contractSize = state.contractSize > 0 ? state.contractSize : 1.0; // 换算为 USDT 名义价值再除以杠杆 // 张: lots × contractSize(USDT/张) = USDT(面值已是 USDT,无需再乘价格) // USDT: 直接 // BTC/ETH/ETC: 数量 × price = USDT final availableMargin = ref.read(futuresProvider(widget.symbol)).accountInfo.availableMargin; final double rawMargin; switch (state.amountUnit) { case AmountUnit.usdt: // USDT 输入为名义仓位价值,除以杠杆得保证金 rawMargin = amount / leverage; case AmountUnit.lots: // 张 × contractSize = 名义价值,再除杠杆得保证金 rawMargin = amount * contractSize / leverage; case AmountUnit.btc: // 基础币 × 价格 = 名义价值,再除杠杆得保证金 rawMargin = amount * price / leverage; } // clamp 后再截断(floor),防止 formatAmount 进位后显示值 > 可用 final clamped = availableMargin > 0 ? rawMargin.clamp(0.0, availableMargin) : rawMargin; final margin = (clamped * 100).floorToDouble() / 100; return formatAmount(margin, decimals: 2); } /// 可开多/可开空数量,与老版 Android 公式一致: /// availableBalance / (((1/leverage) + openFee) * lastPrice) String _calcMaxOpenAmount(FuturesState state) { final refPrice = _refPrice(state); final avail = state.accountInfo.availableMargin; final leverage = state.leverage; final cs = state.contractSize > 0 ? state.contractSize : 1.0; if (avail <= 0 || refPrice <= 0) return '--'; final feeMultiplier = 1 + state.openFeeRate * leverage; final maxNotional = avail / feeMultiplier * leverage; switch (state.amountUnit) { case AmountUnit.usdt: return formatAmount(maxNotional); case AmountUnit.lots: return (maxNotional / cs).floor().toString(); case AmountUnit.btc: return formatAmount(maxNotional / refPrice, decimals: state.coinPrecision); } } /// 可平量(基于仓位可平 size,换算为当前单位) String _calcMaxCloseAmount(FuturesState state, double availSize) { final refPrice = _refPrice(state); final cs = state.contractSize > 0 ? state.contractSize : 1.0; switch (state.amountUnit) { case AmountUnit.btc: return formatAmount(availSize, decimals: state.coinPrecision); case AmountUnit.usdt: return refPrice > 0 ? formatAmount(availSize * refPrice) : '--'; case AmountUnit.lots: return (cs > 0 && refPrice > 0) ? (availSize * refPrice / cs).floor().toString() : '--'; } } void _showSymbolPicker(BuildContext context) { FocusScope.of(context).unfocus(); final targetBasePath = widget.showSpotSwitcher ? '/futures' : '/contracts'; 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, visibleTabs: const [SymbolPickerTab.futures], onSelected: (newSymbol) { Navigator.pop(sheetCtx); context.go('$targetBasePath/$newSymbol'); }, onSpotSelected: (newSymbol) { Navigator.pop(sheetCtx); context.go('$targetBasePath/$newSymbol'); }, ), ); } void _showAmountUnitSheet(BuildContext context, FuturesNotifier notifier) { FocusScope.of(context).unfocus(); final cs = Theme.of(context).colorScheme; final coinLabel = ref.read(futuresProvider(widget.symbol).select((s) { if (s.coinSymbol.isNotEmpty) return s.coinSymbol; final base = widget.symbol.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); return base.isNotEmpty ? base : 'BTC'; })); final currentUnit = ref.read(futuresProvider(widget.symbol).select((s) => s.amountUnit)); // 各单位的标题和描述 final l10n = AppLocalizations.of(context)!; String unitTitle(AmountUnit unit) { switch (unit) { case AmountUnit.btc: return l10n.contractUnitCoin(coinLabel); case AmountUnit.usdt: return l10n.contractUnitUsdt; case AmountUnit.lots: return l10n.contractUnitSheets; } } String unitDesc(AmountUnit unit) { switch (unit) { case AmountUnit.btc: return l10n.contractUnitHintCoin(coinLabel); case AmountUnit.usdt: return l10n.contractUnitHintUsdt; case AmountUnit.lots: return l10n.contractUnitHintSheets; } } showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: cs.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) => StatefulBuilder( builder: (ctx, setState) { AmountUnit selected = currentUnit; return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.contractUnitSetting, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), for (final unit in AmountUnit.values) ...[ GestureDetector( onTap: () { notifier.setAmountUnit(unit); // 单位切换后清空输入和滑块,避免旧值被误用 _amountController.clear(); notifier.setSliderPercent(0); Navigator.pop(ctx); }, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 14), decoration: BoxDecoration( border: Border.all( color: selected == unit ? cs.primary : cs.outline.withAlpha(80), width: selected == unit ? 1.5 : 1, ), borderRadius: BorderRadius.circular(8), color: selected == unit ? cs.primary.withAlpha(15) : Colors.transparent, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( unitTitle(unit), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( unitDesc(unit), style: TextStyle( color: cs.onSurface.withAlpha(140), fontSize: 12, ), ), ], ), ), ), if (unit != AmountUnit.lots) const SizedBox(height: 10), ], ], ), ), ); }, ), ); } } class _MarginLeverageRow extends ConsumerWidget { const _MarginLeverageRow({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final provider = futuresProvider(symbol); final marginMode = ref.watch(provider.select((s) => s.marginMode)); final leverage = ref.watch(provider.select((s) => s.leverage)); final leverageMin = ref.watch(provider.select((s) => s.leverageMin)); final leverageMax = ref.watch(provider.select((s) => s.leverageMax)); final leverageOptions = ref.watch(provider.select((s) => s.leverageOptions)); final isDiscrete = ref.watch(provider.select((s) => s.isDiscreteLeverage)); final notifier = ref.read(provider.notifier); final isLoggedIn = ref.watch(isLoggedInProvider); final isTrader = ref.watch(copyTradingProvider.select((s) => s.isTrader)); return Row( children: [ Builder(builder: (ctx) { final isDark = Theme.of(ctx).brightness == Brightness.dark; return GestureDetector( onTap: () => isLoggedIn ? _showMarginModeSheet(context, marginMode, notifier, isTrader: isTrader) : context.push('/login'), child: Container( height: 26, padding: const EdgeInsets.symmetric(horizontal: 7), decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( switch (marginMode) { MarginMode.cross => AppLocalizations.of(context)!.crossMargin, MarginMode.isolated => AppLocalizations.of(context)!.isolatedMargin, MarginMode.split => AppLocalizations.of(context)!.splitMargin, }, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13, fontWeight: FontWeight.w700)), Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 13), ], ), ), ); }), const SizedBox(width: 8), GestureDetector( onTap: () { if (!isLoggedIn) { context.push('/login'); return; } _showLeverageSheet(context, leverage, leverageMin, leverageMax, leverageOptions, isDiscrete, notifier); }, child: Container( height: 26, padding: const EdgeInsets.symmetric(horizontal: 7), decoration: const BoxDecoration( color: AppColors.leverageGoldBg, borderRadius: BorderRadius.all(Radius.circular(4)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( '${leverage.toInt()}X', style: const TextStyle( color: AppColors.leverageGold, fontSize: 13, fontWeight: FontWeight.w700, fontFeatures: [FontFeature.tabularFigures()], ), ), const Icon(Icons.keyboard_arrow_down, color: AppColors.leverageGold, size: 13), ], ), ), ), ], ); } void _showLeverageSheet( BuildContext context, double leverage, int leverageMin, int leverageMax, List leverageOptions, bool isDiscrete, FuturesNotifier notifier) { FocusScope.of(context).unfocus(); showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) => _LeverageSheet( symbol: symbol, current: leverage, leverageMin: leverageMin, leverageMax: leverageMax, leverageOptions: leverageOptions, isDiscrete: isDiscrete, onChanged: notifier.setLeverage, ), ); } void _showMarginModeSheet( BuildContext context, MarginMode current, FuturesNotifier notifier, {bool isTrader = false}) { FocusScope.of(context).unfocus(); final cs = Theme.of(context).colorScheme; showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: cs.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetCtx) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text(AppLocalizations.of(context)!.marginMode, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600)), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Column( children: [ for (final entry in [ ( MarginMode.cross, AppLocalizations.of(context)!.crossMargin, AppLocalizations.of(context)!.crossMarginDesc, _MarginModeIcon.cross ), // 带单员不支持分仓,隐藏分仓选项 if (!isTrader) ( MarginMode.split, AppLocalizations.of(context)!.splitMargin, AppLocalizations.of(context)!.splitMarginDesc, _MarginModeIcon.split ), ]) ...[ GestureDetector( onTap: () async { Navigator.pop(sheetCtx); final err = await notifier.setMarginMode(entry.$1); if (err != null && context.mounted) { showTopToast(context, message: AppLocalizations.of(context)! .switchMarginModeFailed); } }, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12), decoration: BoxDecoration( border: Border.all( color: current == entry.$1 ? cs.onSurface : cs.outline, width: current == entry.$1 ? 1.5 : 1, ), borderRadius: BorderRadius.circular(10), color: Colors.transparent, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _MarginModeIconWidget(type: entry.$4), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text(entry.$2, style: TextStyle( color: cs.onSurface, fontWeight: FontWeight.w600, fontSize: 14)), if (current == entry.$1) ...[ const SizedBox(width: 6), Icon(Icons.check_circle, size: 16, color: cs.onSurface), ], ], ), const SizedBox(height: 4), Text(entry.$3, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 12, height: 1.4)), ], ), ), ], ), ), ), const SizedBox(height: 10), ], ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), child: Center( child: Text( AppLocalizations.of(context)!.marginModeNote, style: TextStyle( color: Theme.of(sheetCtx).colorScheme.onSurface.withAlpha(120), fontSize: 11, decoration: TextDecoration.underline, decorationColor: Theme.of(sheetCtx).colorScheme.onSurface.withAlpha(120), ), ), ), ), ], )), ), ); } } class _LeverageSheet extends StatefulWidget { const _LeverageSheet({ required this.symbol, required this.current, required this.onChanged, this.leverageMin = 1, this.leverageMax = 125, this.leverageOptions = const [], this.isDiscrete = false, }); final String symbol; final double current; final Future Function(double) onChanged; final int leverageMin; final int leverageMax; final List leverageOptions; /// true=分离倍数(只能选指定档位)false=连续倍数(区间内任意值) final bool isDiscrete; @override State<_LeverageSheet> createState() => _LeverageSheetState(); } class _LeverageSheetState extends State<_LeverageSheet> { late double _value; bool _loading = false; /// 预设档位:优先用接口返回的 leverageOptions,否则用默认 List get _presets { if (widget.leverageOptions.isNotEmpty) return widget.leverageOptions; return [1, 5, 10, 30, 50, 75, 100, 125] .where((v) => v >= widget.leverageMin && v <= widget.leverageMax) .toList(); } @override void initState() { super.initState(); final clamped = widget.current .clamp(widget.leverageMin.toDouble(), widget.leverageMax.toDouble()); if (widget.isDiscrete && widget.leverageOptions.isNotEmpty) { // 离散模式:对齐到最近档位 final cur = clamped.toInt(); final nearest = widget.leverageOptions.reduce( (a, b) => (a - cur).abs() <= (b - cur).abs() ? a : b, ); _value = nearest.toDouble(); } else { _value = clamped; } } void _step(int delta) { if (widget.isDiscrete && _presets.isNotEmpty) { // 离散模式:跳到相邻档位 final cur = _value.toInt(); final idx = _presets.indexOf(cur); int nextIdx; if (idx < 0) { // 当前值不在档位中,找最近的 nextIdx = delta > 0 ? _presets.indexWhere((v) => v > cur) : _presets.lastIndexWhere((v) => v < cur); } else { nextIdx = idx + delta.sign; } if (nextIdx >= 0 && nextIdx < _presets.length) { setState(() => _value = _presets[nextIdx].toDouble()); } } else { final next = (_value + delta) .clamp(widget.leverageMin.toDouble(), widget.leverageMax.toDouble()); setState(() => _value = next); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── 顶部标题栏 ────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Row( children: [ const SizedBox(width: 32), Expanded( child: Column( children: [ Text( AppLocalizations.of(context)! .symbolPerpetual(widget.symbol), style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), textAlign: TextAlign.center, ), const SizedBox(height: 2), Text( AppLocalizations.of(context)!.adjustLeverage, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 12, ), textAlign: TextAlign.center, ), ], ), ), SizedBox( width: 32, child: GestureDetector( onTap: () => Navigator.pop(context), child: Icon(Icons.close, size: 20, color: cs.onSurface.withAlpha(153)), ), ), ], ), ), // ── - | 倍数 | + ──────────────────────────────── Container( height: 60, decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: cs.outline.withAlpha(80)), ), child: Row( children: [ _LeverageStepBtn( label: '−', onTap: () => _step(-1), onLongPress: () => _step(-5), isLeft: true, ), Container( width: 1, color: Theme.of(context).colorScheme.outline.withAlpha(80)), Expanded( child: Center( child: Text( '${_value.toInt()}x', style: TextStyle( color: cs.onSurface, fontSize: 26, fontWeight: FontWeight.w700, ), ), ), ), Container( width: 1, color: Theme.of(context).colorScheme.outline.withAlpha(80)), // + 按钮 _LeverageStepBtn( label: '+', onTap: () => _step(1), onLongPress: () => _step(5), isLeft: false, ), ], ), ), const SizedBox(height: 20), // ── 滑轨(连续模式显示,离散模式隐藏)──────────── if (!widget.isDiscrete) ...[ SliderTheme( data: SliderTheme.of(context).copyWith( activeTrackColor: AppColors.brand, inactiveTrackColor: cs.outline.withAlpha(50), thumbColor: Colors.white, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), overlayColor: cs.onSurface.withAlpha(20), trackHeight: 3, ), child: Slider( value: _value, min: widget.leverageMin.toDouble(), max: widget.leverageMax.toDouble(), divisions: (widget.leverageMax - widget.leverageMin).clamp(1, 999), onChanged: (v) => setState(() => _value = v.roundToDouble()), ), ), const SizedBox(height: 12), ], // ── 档位按钮(两种模式都显示,离散时为唯一选择方式) Wrap( spacing: 8, runSpacing: 8, children: _presets.map((v) { final active = _value.toInt() == v; return GestureDetector( onTap: () => setState(() => _value = v.toDouble()), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: active ? AppColors.brand : cs.outline.withAlpha(25), borderRadius: BorderRadius.circular(8), ), child: Text( '${v}x', style: TextStyle( color: active ? Colors.black : cs.onSurface.withAlpha(153), fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ); }).toList(), ), const SizedBox(height: 24), // ── 确定按钮 ───────────────────────────────────── SizedBox( width: double.infinity, height: 52, child: ElevatedButton( onPressed: _loading ? null : () async { setState(() => _loading = true); final err = await widget.onChanged(_value); if (!context.mounted) return; setState(() => _loading = false); showTopToast( context, message: err ?? AppLocalizations.of(context)! .leverageAdjustedMsg(_value.toInt()), backgroundColor: err != null ? AppColors.fall : AppColors.rise, ); if (err == null) Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(26)), ), child: _loading ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.black), ) : Text(AppLocalizations.of(context)!.confirmLabel, style: TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600)), ), ), ], ), ), ); } } /// 杠杆调节 -/+ 按钮 class _LeverageStepBtn extends StatelessWidget { const _LeverageStepBtn({ required this.label, required this.onTap, required this.onLongPress, this.isLeft = true, }); final String label; final VoidCallback onTap; final VoidCallback onLongPress; final bool isLeft; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final radius = Radius.circular(11); return GestureDetector( onTap: onTap, onLongPress: onLongPress, child: Container( width: 60, alignment: Alignment.center, decoration: BoxDecoration( color: cs.outline.withAlpha(25), borderRadius: isLeft ? BorderRadius.only(topLeft: radius, bottomLeft: radius) : BorderRadius.only(topRight: radius, bottomRight: radius), ), child: Text( label, style: TextStyle( color: cs.onSurface, fontSize: 24, fontWeight: FontWeight.w400, ), ), ), ); } } class _PositionModePills extends ConsumerWidget { const _PositionModePills({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final provider = futuresProvider(symbol); final positionMode = ref.watch(provider.select((s) => s.positionMode)); final notifier = ref.read(provider.notifier); final isOpen = positionMode == PositionMode.open; return Container( height: 30, padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: cs.outline.withAlpha(80), borderRadius: BorderRadius.circular(999), ), child: Row( children: [ _SegPill( label: AppLocalizations.of(context)!.openLabel, active: isOpen, activeColor: AppColors.rise, onTap: () => notifier.setPositionMode(PositionMode.open), ), _SegPill( label: AppLocalizations.of(context)!.closeLabel, active: !isOpen, activeColor: AppColors.fall, onTap: () => notifier.setPositionMode(PositionMode.close), ), ], ), ); } } class _SegPill extends StatelessWidget { const _SegPill({ required this.label, required this.active, required this.activeColor, required this.onTap, }); final String label; final bool active; final Color activeColor; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Expanded( child: GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: active ? activeColor : Colors.transparent, borderRadius: BorderRadius.circular(999), ), alignment: Alignment.center, child: Text( label, style: TextStyle( color: active ? Colors.white : cs.onSurface.withAlpha(153), fontSize: 12, fontWeight: FontWeight.w600, ), ), ), ), ); } } class _OrderTypeDropdown extends ConsumerWidget { const _OrderTypeDropdown({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final provider = futuresProvider(symbol); final orderType = ref.watch(provider.select((s) => s.orderType)); final isClose = ref.watch(provider.select((s) => s.positionMode == PositionMode.close)); final notifier = ref.read(provider.notifier); final isDark = Theme.of(context).brightness == Brightness.dark; return GestureDetector( onTap: () => _showOrderTypeMenu(context, orderType, isClose, notifier), child: Container( height: 36, 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( () { final l10n = AppLocalizations.of(context)!; switch (orderType) { case OrderType.market: return l10n.marketOrder; case OrderType.limit: return l10n.limitOrder; case OrderType.conditionalMarket: return l10n.conditionalMarketOrder; case OrderType.conditionalLimit: return l10n.conditionalLimitOrder; } }(), style: TextStyle(color: cs.onSurface, fontSize: 13), ), Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 18), ], ), ), ); } void _showOrderTypeMenu(BuildContext context, OrderType currentType, bool isClose, FuturesNotifier notifier) { FocusScope.of(context).unfocus(); final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final types = [ (OrderType.market, l10n.marketOrder), (OrderType.limit, l10n.limitOrder), if (!isClose) (OrderType.conditionalMarket, l10n.conditionalMarketOrder), if (!isClose) (OrderType.conditionalLimit, l10n.conditionalLimitOrder), ]; 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: types .map((t) => GestureDetector( onTap: () { notifier.setOrderType(t.$1); Navigator.pop(sheetCtx); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14), child: Row( children: [ Expanded( child: Text(t.$2, style: TextStyle( color: currentType == t.$1 ? AppColors.brand : cs.onSurface, fontSize: 14)), ), if (currentType == t.$1) const Icon(Icons.check, color: AppColors.brand, size: 18), ], ), ), )) .toList(), ), ), ); } } // ── 保证金模式图标 ───────────────────────────────────────────── enum _MarginModeIcon { cross, split } class _MarginModeIconWidget extends StatelessWidget { const _MarginModeIconWidget({required this.type}); final _MarginModeIcon type; @override Widget build(BuildContext context) { final asset = switch (type) { _MarginModeIcon.cross => 'assets/images/eg_ticket_dark.png', _MarginModeIcon.split => 'assets/images/eg_split.png', }; return Image.asset(asset, width: 52, height: 52, fit: BoxFit.contain); } } class _LargeInput extends StatefulWidget { const _LargeInput({ super.key, required this.controller, required this.label, required this.unit, this.suffixDropdown, this.showUnitDropdown = false, this.onUnitTap, this.inputFormatters, }); final TextEditingController controller; final String label; final String unit; final String? suffixDropdown; 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) { // controller 实例变化时:移除旧监听、绑定新监听,并同步动画状态 oldWidget.controller.removeListener(_onChanged); widget.controller.addListener(_onChanged); if (_isActive) { _animCtrl.value = 1.0; } else { _animCtrl.value = 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 activeBorderColor = 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: activeBorderColor, 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 labelHeight = labelSize * 1.0; final centerTop = (44.0 - labelHeight) / 2; const activeTop = 5.0; final labelTop = centerTop + (activeTop - centerTop) * t; final inputOpacity = 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: inputOpacity, 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), ], ), ), if (widget.suffixDropdown != null) ...[ const SizedBox(width: 6), Row( mainAxisSize: MainAxisSize.min, children: [ Text(widget.suffixDropdown!, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), Icon(Icons.keyboard_arrow_down, color: cs.onSurface.withAlpha(153), size: 14), ], ), ], ], ), ), ); } } class _PercentSlider extends StatefulWidget { const _PercentSlider({ required this.percent, required this.onChanged, this.showQuickButtons = true, this.enabled = true, }); final double percent; final ValueChanged onChanged; final bool showQuickButtons; /// false 时忽略所有手势,仅展示当前进度 final bool enabled; @override State<_PercentSlider> createState() => _PercentSliderState(); } class _PercentSliderState extends State<_PercentSlider> { // 行业标准快捷百分比(25/50/75/100,与主流交易所一致) static const _quickPcts = [0.0, 0.25, 0.50, 0.75, 1.00]; static const _tickPcts = [0.0, 0.25, 0.50, 0.75, 1.00]; static const _thumbSize = 18.0; static const _trackH = _thumbSize; bool _isDragging = false; double _lastHapticPct = -1; // OverlayPortal:气泡渲染到 Overlay 层,不受任何祖先裁剪影响 final _overlayCtrl = OverlayPortalController(); // CompositedTransformTarget key,用于获取滑块在屏幕中的绝对位置 final _layerLink = LayerLink(); double _trackWidth = 0; void _onDrag(double dx, double w) { // 有效轨道范围:两端各留半个滑块 const r = _thumbSize / 2; final trackW = w - _thumbSize; final newPct = ((dx - r) / trackW).clamp(0.0, 1.0); final lastStep = (_lastHapticPct * 100).floor(); final newStep = (newPct * 100).floor(); if (newStep != lastStep) HapticFeedback.selectionClick(); _lastHapticPct = newPct; widget.onChanged(newPct); } void _startDrag(double dx, double w) { _trackWidth = w; setState(() => _isDragging = true); _overlayCtrl.show(); _onDrag(dx, w); } void _endDrag() { setState(() => _isDragging = false); _overlayCtrl.hide(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final percent = widget.percent; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 滑轨区 ─────────────────────────────────────────── SizedBox( height: _trackH, child: LayoutBuilder( builder: (_, box) { final w = box.maxWidth; // 轨道有效宽度:两端各留半个滑块,使 0%/100% 时滑块完整显示 const r = _thumbSize / 2; final trackW = w - _thumbSize; final thumbX = r + trackW * percent; return CompositedTransformTarget( link: _layerLink, child: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: widget.enabled ? (d) => _startDrag(d.localPosition.dx, w) : null, onHorizontalDragUpdate: widget.enabled ? (d) => _onDrag(d.localPosition.dx, w) : null, onHorizontalDragEnd: widget.enabled ? (_) => _endDrag() : null, onTapDown: widget.enabled ? (d) { // 触点转换为有效轨道范围内的百分比 final pct = ((d.localPosition.dx - r) / trackW) .clamp(0.0, 1.0); HapticFeedback.selectionClick(); _lastHapticPct = pct; widget.onChanged(pct); } : null, // OverlayPortal:气泡挂载到 Overlay,脱离布局树 child: OverlayPortal( controller: _overlayCtrl, overlayChildBuilder: (_) { final tw = _trackWidth > 0 ? _trackWidth : w; // 滑块圆心的 X:与 thumbX 计算方式一致 const bubbleR = _thumbSize / 2; final effW = tw - _thumbSize; final thumbCx = bubbleR + effW * percent; // 气泡宽40,对齐滑块中心,边界夹紧 final bubbleOffsetX = (thumbCx - 20).clamp(0.0, tw - 40.0); // 气泡高30(24容器+6箭头),置于滑块上方 4px const bubbleH = 30.0; return Positioned( left: 0, top: 0, child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, // 从 target 左上角偏移:x=气泡位置,y=轨道上方 offset: Offset(bubbleOffsetX, -(bubbleH + 4)), child: _PercentBubble(percent: percent), ), ); }, child: Stack( children: [ // 背景轨道(两端与有效轨道对齐,即 r ~ w-r) Positioned( top: (_trackH - 3) / 2, left: r, right: r, height: 3, child: Container( decoration: BoxDecoration( color: cs.outline.withAlpha(50), borderRadius: BorderRadius.circular(2), ), ), ), // 已填充轨道(从 r 开始到滑块圆心) Positioned( top: (_trackH - 3) / 2, left: r, width: thumbX - r, height: 3, child: Container( decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(2), ), ), ), // 刻度点(在有效轨道范围内按百分比定位) for (final p in _tickPcts) Positioned( left: r + trackW * p - 3, top: (_trackH - 6) / 2, child: Container( width: 6, height: 6, decoration: BoxDecoration( shape: BoxShape.circle, color: p <= percent ? AppColors.brand : cs.outline.withAlpha(80), ), ), ), // 滑块把手(圆心在 thumbX) 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), ), ], ), ), ), ], ), ), ), ); }, ), ), if (widget.showQuickButtons) ...[ const SizedBox(height: 4), // ── 快捷按钮 0/25/50/75/100:Stack+Positioned,按钮中心与刻度对齐 ── LayoutBuilder(builder: (_, box) { final w = box.maxWidth; const r = _thumbSize / 2; final trackW = w - _thumbSize; // 相邻刻度间距,减去固定间隙 6px,保证各机型按钮间隔一致 final tickInterval = trackW / (_quickPcts.length - 1); final btnW = (tickInterval - 6).clamp(20.0, 64.0); return SizedBox( height: 22, child: Stack( children: [ for (final pct in _quickPcts) Positioned( // 按钮中心对齐刻度圆心,边界夹紧防止溢出 left: (r + trackW * pct - btnW / 2).clamp(0.0, w - btnW), top: 0, width: btnW, height: 22, child: Builder(builder: (_) { final isSelected = (percent - pct).abs() < 0.001; return GestureDetector( onTap: widget.enabled ? () { HapticFeedback.selectionClick(); _lastHapticPct = pct; widget.onChanged(pct); } : null, child: Container( decoration: BoxDecoration( color: isSelected ? AppColors.brand : cs.outline.withAlpha(25), borderRadius: BorderRadius.circular(4), ), alignment: Alignment.center, child: Text( '${(pct * 100).toInt()}%', style: TextStyle( color: isSelected ? Colors.black : cs.onSurface.withAlpha(102), fontSize: 10, fontWeight: FontWeight.w500, ), ), ), ); }), ), ], ), ); }), ], ], ); } } /// 拖动时显示在滑块上方的百分比气泡(气泡 + 向下三角箭头) class _PercentBubble extends StatelessWidget { const _PercentBubble({required this.percent}); final double percent; @override Widget build(BuildContext context) { final label = '${(percent * 100).round()}%'; return Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 24, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(6), ), child: Text( label, style: const TextStyle( color: Colors.black, fontSize: 11, fontWeight: FontWeight.w700, ), ), ), CustomPaint( size: const Size(8, 6), painter: _BubbleArrowPainter(), ), ], ); } } class _BubbleArrowPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = AppColors.brand; final path = Path() ..moveTo(0, 0) ..lineTo(size.width / 2, size.height) ..lineTo(size.width, 0) ..close(); canvas.drawPath(path, paint); } @override bool shouldRepaint(_BubbleArrowPainter _) => false; } class _TpslSection extends StatelessWidget { const _TpslSection({ required this.enabled, required this.onToggle, required this.tpController, required this.slController, }); final bool enabled; final VoidCallback onToggle; final TextEditingController tpController; final TextEditingController slController; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Column( children: [ GestureDetector( onTap: onToggle, child: Row( children: [ Container( width: 16, height: 16, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( color: enabled ? AppColors.brand : cs.onSurface.withAlpha(153), width: 1.5, ), color: enabled ? AppColors.brand : Colors.transparent, ), child: enabled ? const Icon(Icons.check, size: 12, color: Colors.black) : null, ), const SizedBox(width: 6), Text(AppLocalizations.of(context)!.takeProfitStopLoss, style: TextStyle(color: cs.onSurface, fontSize: 13)), ], ), ), if (enabled) ...[ const SizedBox(height: 6), _SmallInput( controller: tpController, hint: '${AppLocalizations.of(context)!.takeProfitPrice} (USDT)', ), const SizedBox(height: 4), _SmallInput( controller: slController, hint: '${AppLocalizations.of(context)!.stopLossPrice} (USDT)', ), ], ], ); } } class _SmallInput extends StatefulWidget { const _SmallInput({required this.controller, required this.hint}); final TextEditingController controller; final String hint; @override State<_SmallInput> createState() => _SmallInputState(); } class _SmallInputState extends State<_SmallInput> { final _focusNode = FocusNode(); @override void initState() { super.initState(); _focusNode.addListener(() { if (mounted) setState(() {}); }); } @override void dispose() { _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final isFocused = _focusNode.hasFocus; final bgColor = isFocused ? (isDark ? AppColors.darkBgSecondary : Colors.white) : (isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary); final activeBorder = isDark ? AppColors.darkTextPrimary.withAlpha(200) : const Color(0xFF383838); return Container( height: 34, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(6), border: isFocused ? Border.all(color: activeBorder, width: 1.5) : null, ), child: TextField( controller: widget.controller, focusNode: _focusNode, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: TextStyle(color: cs.onSurface, fontSize: 12), decoration: InputDecoration( hintText: widget.hint, hintStyle: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, filled: false, isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), ), ), ); } } // 资金费率行 — 只订阅 fundingRate + fundingCountdown,每秒更新一次,不触发盘口重建 class _FundingRateRow extends ConsumerWidget { const _FundingRateRow({required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final provider = futuresProvider(symbol); final fundingRate = ref.watch(provider.select((s) => s.fundingRate)); final fundingCountdown = ref.watch(provider.select((s) => s.fundingCountdown)); return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(AppLocalizations.of(context)!.fundingRateCountdown, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), Text( '${fundingRate >= 0 ? '+' : ''}${(fundingRate * 100).toStringAsFixed(4)}% / $fundingCountdown', style: TextStyle( color: cs.onSurface, fontSize: 13, fontFeatures: const [FontFeature.tabularFigures()]), ), ], ), ], ); } } class _OrderBookPanel extends ConsumerStatefulWidget { const _OrderBookPanel({ required this.symbol, required this.rowCount, this.rowHeight = 22.0, this.onPriceTap, }); final String symbol; final int rowCount; final double rowHeight; final ValueChanged? onPriceTap; @override ConsumerState<_OrderBookPanel> createState() => _OrderBookPanelState(); } /// 预计算后的单行盘口数据,避免 build() 内重复调用格式化函数 class _BookRowData { const _BookRowData({ required this.price, required this.qty, required this.formattedPrice, required this.depthPercent, }); final double price; final double qty; final String formattedPrice; final double depthPercent; } class _OrderBookPanelState extends ConsumerState<_OrderBookPanel> { // 订单薄显示模式: 0=双向, 1=仅卖盘, 2=仅买盘 int _bookMode = 0; // 缓存:只在 rawAsks/rawBids/bookMode 变化时重新计算 List>? _prevAsks; List>? _prevBids; int _prevBookMode = -1; int _prevRowCount = -1; int _prevPricePrecision = -1; List<_BookRowData> _askRows = const []; List<_BookRowData> _bidRows = const []; double _bidRatio = 0.5; void _recompute( List> rawAsks, List> rawBids, double minTick, int decimalDigits, ) { _prevAsks = rawAsks; _prevBids = rawBids; _prevBookMode = _bookMode; _prevRowCount = widget.rowCount; _prevPricePrecision = decimalDigits; final aggAsks = _bookMode == 2 ? >[] : rawAsks; final aggBids = _bookMode == 1 ? >[] : rawBids; final askRows = _bookMode == 2 ? 0 : (_bookMode == 1 ? widget.rowCount * 2 : widget.rowCount); final bidRows = _bookMode == 1 ? 0 : (_bookMode == 2 ? widget.rowCount * 2 : widget.rowCount); final askSlice = aggAsks.take(askRows).toList(); final bidSlice = aggBids.take(bidRows).toList(); double totalAsk = 0, totalBid = 0, maxQ = 0.001; for (var e in askSlice) { final q = _toDouble(e['quantity']); totalAsk += q; if (q > maxQ) maxQ = q; } for (var e in bidSlice) { final q = _toDouble(e['quantity']); totalBid += q; if (q > maxQ) maxQ = q; } final total = totalAsk + totalBid; _bidRatio = total > 0 ? totalBid / total : 0.5; // 卖盘:slice 升序,UI 反转(最高价在顶) _askRows = List.generate(askRows, (i) { final ri = askSlice.length - 1 - i; if (ri < 0) return const _BookRowData( price: 0, qty: 0, formattedPrice: '', depthPercent: 0); final price = _toDouble(askSlice[ri]['price']); final qty = _toDouble(askSlice[ri]['quantity']); return _BookRowData( price: price, qty: qty, formattedPrice: _fmtPrice(price, minTick, decimalDigits), depthPercent: qty / maxQ, ); }); _bidRows = List.generate(bidRows, (i) { if (i >= bidSlice.length) return const _BookRowData( price: 0, qty: 0, formattedPrice: '', depthPercent: 0); final price = _toDouble(bidSlice[i]['price']); final qty = _toDouble(bidSlice[i]['quantity']); return _BookRowData( price: price, qty: qty, formattedPrice: _fmtPrice(price, minTick, decimalDigits), depthPercent: qty / maxQ, ); }); } // 根据合约价格精度计算小数位数 int _decimalDigits(int pricePrecision) { if (pricePrecision <= 0) return 0; return pricePrecision; } // 将价格按精度对齐(始终使用合约最小精度档) double _roundToPrecision(double price, double minTick) { return (price / minTick).round() * minTick; } String _fmtPrice(double price, double minTick, int decimalDigits) { final str = price.toString(); // 如果原始字符串中有小数点,直接返回;否则返回固定格式 return str.contains('.') ? str : price.toStringAsFixed(0); } // 将原始订单列表按精度合并:相同价格档位的数量求和 // isSell=true → 按价格升序(卖盘,低价在前) // isSell=false → 按价格降序(买盘,高价在前) List> _aggregate( List> raw, bool isSell, double minTick) { final Map bucket = {}; for (final o in raw) { final p = _toDouble(o['price']); final q = _toDouble(o['quantity']); if (p <= 0 || q <= 0) continue; final key = _roundToPrecision(p, minTick); bucket[key] = (bucket[key] ?? 0) + q; } final entries = bucket.entries.toList() ..sort((a, b) => isSell ? a.key.compareTo(b.key) // 卖盘升序 : b.key.compareTo(a.key)); // 买盘降序 return entries.map((e) => {'price': e.key, 'quantity': e.value}).toList(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final provider = futuresProvider(widget.symbol); final lastPrice = ref.watch(provider.select((s) => s.lastPrice)); final lastPriceStr = ref.watch(provider.select((s) => s.lastPriceStr)); final rawAsks = ref.watch(provider.select((s) => s.orderBookAsks)); final rawBids = ref.watch(provider.select((s) => s.orderBookBids)); final pricePrecision = ref.watch(provider.select((s) => s.pricePrecision)); final decimalDigits = _decimalDigits(pricePrecision); final minTick = pricePrecision >= 0 ? pow(10, pricePrecision).toDouble() : 1.0 / pow(10, -pricePrecision).toDouble(); // 只在数据或模式变化时重新计算行数据(避免每帧重复 O(n) 工作) if (!identical(rawAsks, _prevAsks) || !identical(rawBids, _prevBids) || _bookMode != _prevBookMode || widget.rowCount != _prevRowCount || decimalDigits != _prevPricePrecision) { _recompute(rawAsks, rawBids, minTick, decimalDigits); } 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: [ // 资金费率独立 widget,每秒重建,不影响盘口行 RepaintBoundary(child: _FundingRateRow(symbol: widget.symbol)), const SizedBox(height: 2), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(AppLocalizations.of(context)!.priceUsdt, overflow: TextOverflow.ellipsis, style: labelStyle)), const SizedBox(width: 4), Flexible( child: Text( AppLocalizations.of(context)! .amountLabel2(_baseCoin(widget.symbol)), overflow: TextOverflow.ellipsis, style: labelStyle)), ], ), const SizedBox(height: 4), // 卖盘 if (_bookMode != 2) for (var i = 0; i < _askRows.length; i++) _askRows[i].price > 0 ? RepaintBoundary( key: ValueKey('ask_$i'), child: _BookRow( price: _askRows[i].price, isSell: true, amount: _askRows[i].qty.toStringAsFixed(4), depthPercent: _askRows[i].depthPercent, formattedPrice: _askRows[i].formattedPrice, rowHeight: widget.rowHeight, onTap: widget.onPriceTap != null ? () => widget.onPriceTap!(_askRows[i].price) : null, ), ) : _BookRowPlaceholder( 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( lastPriceStr != null ? formatRawPrice(lastPriceStr) : formatPrice(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( '≈\$${formatPrice(lastPrice * 0.9999)}', 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++) _bidRows[i].price > 0 ? RepaintBoundary( key: ValueKey('bid_$i'), child: _BookRow( price: _bidRows[i].price, isSell: false, amount: _bidRows[i].qty.toStringAsFixed(4), depthPercent: _bidRows[i].depthPercent, formattedPrice: _bidRows[i].formattedPrice, rowHeight: widget.rowHeight, onTap: widget.onPriceTap != null ? () => widget.onPriceTap!(_bidRows[i].price) : null, ), ) : _BookRowPlaceholder( key: ValueKey('bid_ph_$i'), rowHeight: widget.rowHeight), const Spacer(), // 撑开剩余空间,让底部元素贴底 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: [ 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: _BookModeIcon(mode: _bookMode), ), ), ], ), ], ), ); } } class _BookRow extends StatelessWidget { const _BookRow({ super.key, required this.price, required this.isSell, required this.amount, this.depthPercent = 0.3, this.formattedPrice, this.rowHeight = 22.0, this.onTap, }); final double price; final bool isSell; final String amount; final double depthPercent; final String? formattedPrice; 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: (_, constraints) { final barW = constraints.maxWidth * depthPercent.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: barW, decoration: BoxDecoration( color: color.withAlpha(38), borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(formattedPrice ?? formatPrice(price), style: TextStyle( color: color, fontSize: 13, fontWeight: FontWeight.w500, fontFeatures: const [ FontFeature.tabularFigures() ])), Text(amount, style: TextStyle( color: cs.onSurface, fontSize: 13, fontFeatures: const [ FontFeature.tabularFigures() ])), ], ), ), ], ); }, ), ), ); } } /// 订单薄模式图标:3种视觉样式 /// mode=0 双向 | mode=1 仅卖盘 | mode=2 仅买盘 class _BookModeIcon extends StatelessWidget { const _BookModeIcon({required this.mode}); final int mode; @override Widget build(BuildContext context) { const sellColor = AppColors.fall; const buyColor = AppColors.rise; const emptyColor = Color(0xFFCCCCCC); final double lineH = 2.0; final double gap = 1.5; Widget line(Color color) => Container( height: lineH, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(1), ), ); // 3 lines: top=sell side, bottom=buy side // mode0: top 1.5 sell lines + 1.5 buy lines // mode1: all sell (red) // mode2: all buy (green) 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, ); } } /// 无数据时的占位行,高度与 _BookRow 保持一致 class _BookRowPlaceholder extends StatelessWidget { const _BookRowPlaceholder({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), )), ], ), ), ); } } // ── 自适应高度辅助 ───────────────────────────────────────── // 用 OverflowBox 解除父级高度约束,让子内容按自然高度布局, // 再通过 addPostFrameCallback 读取真实 RenderBox 高度上报给父级。 // 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); } }); // OverflowBox:宽度跟父级一致,高度不设上限,让子内容自由伸展。 // 这样 RenderBox.size.height 反映的是内容的真实高度,而不是父级约束高度。 return OverflowBox( alignment: Alignment.topLeft, minHeight: 0, maxHeight: double.infinity, child: KeyedSubtree(key: _key, child: widget.child), ); } } class _BottomSection extends ConsumerStatefulWidget { const _BottomSection({required this.symbol}); final String symbol; @override ConsumerState<_BottomSection> createState() => _BottomSectionState(); } class _BottomSectionState extends ConsumerState<_BottomSection> { late PageController _pageController; bool _programmaticSwitch = false; // 三个 Tab 各自测量到的高度。 // 初始值需能容纳 shimmer 占位内容(positions rows=2 ≈252px,orders rows=3 ≈380px, // assets ≈260px),避免第一帧溢出。OverflowBox 测量到真实高度后会立即更新。 final _tabHeights = [280.0, 420.0, 280.0]; static int _tabToIndex(FuturesTab tab) { switch (tab) { case FuturesTab.positions: return 0; case FuturesTab.orders: return 1; case FuturesTab.assets: return 2; } } static FuturesTab _indexToTab(int index) { switch (index) { case 0: return FuturesTab.positions; case 1: return FuturesTab.orders; default: return FuturesTab.assets; } } @override void initState() { super.initState(); final initial = ref.read(futuresProvider(widget.symbol)).activeTab; _pageController = PageController(initialPage: _tabToIndex(initial)); } @override void dispose() { _pageController.dispose(); super.dispose(); } void _onTabTap(FuturesTab tab) { _programmaticSwitch = true; ref.read(futuresProvider(widget.symbol).notifier).setTab(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 symbol = widget.symbol; final provider = futuresProvider(symbol); final activeTab = ref.watch(provider.select((s) => s.activeTab)); final positionsCount = ref.watch(provider.select((s) => s.positions.length)); final ordersCount = ref.watch(provider.select((s) => s.openOrders.length)); final isTabLoading = ref.watch(provider.select((s) => s.isTabLoading)); return Container( decoration: BoxDecoration( border: Border(top: BorderSide(color: cs.outline)), ), child: Column( children: [ // Tab 头 Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ _BottomTab( label: AppLocalizations.of(context)!.positionsTab, count: positionsCount > 0 ? '($positionsCount)' : null, countColor: AppColors.fall, active: activeTab == FuturesTab.positions, onTap: () => _onTabTap(FuturesTab.positions), ), const SizedBox(width: 16), _BottomTab( label: AppLocalizations.of(context)!.currentOrders, count: ordersCount > 0 ? '($ordersCount)' : null, active: activeTab == FuturesTab.orders, onTap: () => _onTabTap(FuturesTab.orders), ), const SizedBox(width: 16), _BottomTab( label: AppLocalizations.of(context)!.assets, active: activeTab == FuturesTab.assets, onTap: () => _onTabTap(FuturesTab.assets), ), const Spacer(), GestureDetector( onTap: () async { final notifier = ref.read(futuresProvider(symbol).notifier); notifier.stopPolling(); await context.push('/futures/$symbol/history'); if (context.mounted) notifier.resumePolling(symbol); }, child: Icon(Icons.access_time, color: cs.onSurface.withAlpha(153), size: 18), ), ], ), ), // 工具栏(持仓/委托各有操作按钮) AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: activeTab == FuturesTab.positions ? _PositionToolbar( key: const ValueKey('pos_toolbar'), symbol: symbol) : activeTab == FuturesTab.orders ? _OrderToolbar( key: const ValueKey('ord_toolbar'), symbol: symbol) : const SizedBox.shrink(key: ValueKey('no_toolbar')), ), // 滑动内容区 // PageView 在 Column 内必须有有界高度。 // 通过 _MeasureSize 让每个 Tab 上报自身真实高度,取三者最大值, // 避免硬编码估算值在内容变化时溢出。 SizedBox( height: _tabHeights.reduce(max).clamp(100.0, 2000.0), child: RepaintBoundary( child: PageView( controller: _pageController, physics: const ClampingScrollPhysics(), onPageChanged: (index) { if (_programmaticSwitch) { _programmaticSwitch = false; return; } final tab = _indexToTab(index); ref.read(futuresProvider(symbol).notifier).setTab(tab); }, children: [ _MeasureSize( onSize: (h) { if (_tabHeights[0] != h) setState(() => _tabHeights[0] = h); }, child: _BottomTabContent( symbol: symbol, tab: FuturesTab.positions), ), _MeasureSize( onSize: (h) { if (_tabHeights[1] != h) setState(() => _tabHeights[1] = h); }, child: _BottomTabContent( symbol: symbol, tab: FuturesTab.orders), ), _MeasureSize( onSize: (h) { if (_tabHeights[2] != h) setState(() => _tabHeights[2] = h); }, child: _BottomTabContent( symbol: symbol, tab: FuturesTab.assets), ), ], ), ), ), ], ), ); } } class _BottomTabContent extends ConsumerWidget { const _BottomTabContent({required this.symbol, required this.tab}); final String symbol; final FuturesTab tab; @override Widget build(BuildContext context, WidgetRef ref) { final provider = futuresProvider(symbol); final isTabLoading = ref.watch(provider.select((s) => s.isTabLoading)); switch (tab) { case FuturesTab.positions: final positions = ref.watch(provider.select((s) => s.displayPositions)); if (isTabLoading && positions.isEmpty) return const _TabShimmer(rows: 2); return _PositionsList(symbol: symbol, positions: positions); case FuturesTab.orders: final openOrders = ref.watch(provider.select((s) => s.displayOrders)); if (isTabLoading && openOrders.isEmpty) return const _TabShimmer(rows: 3); return _OrdersList(symbol: symbol, orders: openOrders); case FuturesTab.assets: final accountInfo = ref.watch(provider.select((s) => s.accountInfo)); if (isTabLoading && accountInfo.totalBalance == 0) return const _AssetShimmer(); return _AssetsPanel(info: accountInfo); } } } class _PositionToolbar extends ConsumerWidget { const _PositionToolbar({super.key, required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final notifier = ref.read(futuresProvider(symbol).notifier); final hideOther = ref.watch(futuresProvider(symbol).select((s) => s.hideOtherSymbols)); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ GestureDetector( onTap: notifier.toggleHideOtherSymbols, child: Row( children: [ _CheckBox(checked: hideOther), const SizedBox(width: 4), Text(AppLocalizations.of(context)!.hideOtherPairs, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ], ), ), const Spacer(), GestureDetector( onTap: () => _closeAll(context, ref, notifier), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: cs.inverseSurface, borderRadius: BorderRadius.circular(4), ), child: Text(AppLocalizations.of(context)!.closeAllPositions, style: TextStyle( color: cs.onInverseSurface, fontSize: 11, fontWeight: FontWeight.w500)), ), ), ], ), ); } Future _closeAll( BuildContext context, WidgetRef ref, FuturesNotifier notifier) async { if (!_requireLogin(context, ref)) return; final l10n = AppLocalizations.of(context)!; final confirmed = await _showFuturesConfirm( context, message: l10n.closeAllConfirm, subMessage: l10n.closeAllSubMsg, ); if (!confirmed || !context.mounted) return; final err = await notifier.closeAllPositions(); if (!context.mounted) return; final l10n2 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n2) ?? l10n2.closeAllSubmitted, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } } class _OrderToolbar extends ConsumerWidget { const _OrderToolbar({super.key, required this.symbol}); final String symbol; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final notifier = ref.read(futuresProvider(symbol).notifier); final hideOther = ref.watch(futuresProvider(symbol).select((s) => s.hideOtherSymbols)); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ GestureDetector( onTap: notifier.toggleHideOtherSymbols, child: Row( children: [ _CheckBox(checked: hideOther), const SizedBox(width: 4), Text(AppLocalizations.of(context)!.hideOtherPairs, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), ], ), ), const Spacer(), GestureDetector( onTap: () => _cancelAll(context, ref, notifier), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: cs.inverseSurface, borderRadius: BorderRadius.circular(4), ), child: Text(AppLocalizations.of(context)!.cancelAllOrders, style: TextStyle( color: cs.onInverseSurface, fontSize: 11, fontWeight: FontWeight.w500)), ), ), ], ), ); } Future _cancelAll( BuildContext context, WidgetRef ref, FuturesNotifier notifier) async { if (!_requireLogin(context, ref)) return; final err = await notifier.cancelAllOrders(); if (!context.mounted) return; final l10n3 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n3) ?? l10n3.cancelAllSuccess, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } } class _BottomTab extends StatelessWidget { const _BottomTab({ required this.label, required this.active, required this.onTap, this.count, this.countColor, }); final String label; final bool active; final VoidCallback onTap; /// 数量 badge 文字,如 "(2)" final String? count; /// badge 文字颜色,默认灰色 final Color? countColor; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final labelColor = active ? cs.onSurface : cs.onSurface.withAlpha(153); return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: active ? AppColors.brand : Colors.transparent, width: 2, ), ), ), child: count != null ? RichText( text: TextSpan( children: [ TextSpan( text: label, style: TextStyle( color: labelColor, fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ), TextSpan( text: count, style: TextStyle( color: countColor ?? cs.onSurface.withAlpha(153), fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ), ], ), ) : Text( label, style: TextStyle( color: labelColor, fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w400, ), ), ), ); } } class _PositionsList extends StatelessWidget { const _PositionsList({required this.symbol, required this.positions}); final String symbol; final List positions; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; if (positions.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Center( child: Text(AppLocalizations.of(context)!.noPositions, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)), ), ); } return ListView.builder( padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: positions.length, itemBuilder: (_, i) => RepaintBoundary( key: ValueKey('pos_${positions[i].id}'), child: _PositionCard(symbol: symbol, position: positions[i]), ), ); } } class _PositionCard extends ConsumerWidget { const _PositionCard({required this.symbol, required this.position}); final String symbol; final FuturesPosition position; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final notifier = ref.read(futuresProvider(symbol).notifier); final coinSymbol = _baseCoin(position.symbol); final isLong = position.side == OrderSide.long; final pnlColor = position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall; final sideColor = isLong ? AppColors.rise : AppColors.fall; final coinName = coinSymbol; // 扁平卡片:border-bottom 分割,padding 10 12,与原型 .pos-card 对齐 return GestureDetector( onTap: () async { notifier.stopPolling(); await context.push('/futures/$symbol/position-detail', extra: position); if (context.mounted) notifier.resumePolling(symbol); }, child: Container( decoration: BoxDecoration( border: Border(bottom: BorderSide(color: cs.outline.withAlpha(50))), ), padding: const EdgeInsets.fromLTRB(12, 10, 12, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── 标题行 ────────────────────────────────────────── Row( children: [ // 多/空 彩色徽标 Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: sideColor, borderRadius: BorderRadius.circular(3), ), child: Text( isLong ? AppLocalizations.of(context)!.openLong : AppLocalizations.of(context)!.openShort, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), ), ), const SizedBox(width: 4), // Expanded 包裹所有中间信息,分享按钮固定在最右 Expanded( child: Row( children: [ Flexible( child: Text( '${coinName}USDT', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w700), ), ), const SizedBox(width: 4), _SmallTag( text: AppLocalizations.of(context)!.perpetual), const SizedBox(width: 3), _SmallTag( text: _marginModeLabel(position.marginMode, AppLocalizations.of(context)!), color: _marginModeColor(position.marginMode)), const SizedBox(width: 3), // 杠杆 黄色徽标 Container( padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 1), decoration: BoxDecoration( color: AppColors.leverageGoldBg, borderRadius: BorderRadius.circular(3), ), child: Text( '${position.leverage.toInt()}X', style: const TextStyle( color: AppColors.leverageGold, fontSize: 10, fontWeight: FontWeight.w700), ), ), ], ), ), const SizedBox(width: 8), // 分享按钮固定在最右 GestureDetector( onTap: () => _sharePosition(context), child: Icon(Icons.share_outlined, size: 16, color: cs.onSurface.withAlpha(153)), ), ], ), const SizedBox(height: 8), // ── 未实现盈亏 + 收益率 ────────────────────────────── Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${AppLocalizations.of(context)!.unrealizedPnl} (USDT)', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 9)), Text( formatAmount(position.unrealizedPnl), style: TextStyle( color: pnlColor, fontSize: 16, fontWeight: FontWeight.w700), ), ], ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(AppLocalizations.of(context)!.returnRate, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 9)), Text( '${formatAmount(position.roe)}%', style: TextStyle( color: pnlColor, fontSize: 13, fontWeight: FontWeight.w600), ), ], ), ], ), const SizedBox(height: 6), // ── 数据行 1 ──────────────────────────────────────── Row( children: [ _DataCol( label: '${AppLocalizations.of(context)!.positionSize}($coinSymbol)', value: formatQuantity(position.size), align: CrossAxisAlignment.start), _DataCol( label: '${AppLocalizations.of(context)!.marginLabel}(USDT)', value: formatAmount(position.margin), align: CrossAxisAlignment.center), _DataCol( label: AppLocalizations.of(context)!.marginRatioLabel, value: '${formatAmount(position.marginRatio)}%', align: CrossAxisAlignment.end), ], ), const SizedBox(height: 4), // ── 数据行 2 ──────────────────────────────────────── Row( children: [ _DataCol( label: '${AppLocalizations.of(context)!.openAvgPrice}(USDT)', value: formatPrice(position.entryPrice), align: CrossAxisAlignment.start), _DataCol( label: '${AppLocalizations.of(context)!.latestLabel}(USDT)', value: formatPrice(position.markPrice), align: CrossAxisAlignment.center), _DataCol( label: '${AppLocalizations.of(context)!.liqPrice}(USDT)', value: position.liquidationPrice > 0 ? formatPrice(position.liquidationPrice) : '--', valueColor: position.liquidationPrice > 0 ? AppColors.fall : cs.onSurface.withAlpha(153), align: CrossAxisAlignment.end, ), ], ), const SizedBox(height: 8), // ── 操作按钮 ── Row( children: [ _ActionBtn( text: AppLocalizations.of(context)!.takeProfitStopLossBtn, onTap: () => _showTpslSheet(context, ref, notifier)), const SizedBox(width: 5), _ActionBtn( text: AppLocalizations.of(context)!.reversePositionBtn, onTap: () => _reverse(context, ref, notifier)), const SizedBox(width: 5), _ActionBtn( text: AppLocalizations.of(context)!.closePositionBtn, onTap: () => _showCloseSheet(context, ref, notifier)), const SizedBox(width: 5), Expanded( child: SizedBox( height: 28, child: ElevatedButton( onPressed: () => _closeMarket(context, notifier), style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4)), elevation: 0, padding: EdgeInsets.zero, ), child: Text( AppLocalizations.of(context)!.closeAllMarket, style: const TextStyle( color: Colors.black, fontSize: 11, fontWeight: FontWeight.w500)), ), ), ), ], ), ], ), )); // GestureDetector + Container } /// 分享仓位:弹出预览底部弹窗,用户确认后生成图片并调用系统分享 void _sharePosition(BuildContext context) { showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => SharePositionSheet(position: position), ); } Future _closeMarket( BuildContext context, FuturesNotifier notifier) async { final l10n = AppLocalizations.of(context)!; final confirmed = await _showFuturesConfirm( context, message: l10n.closeAllMarketConfirm, subMessage: l10n.closeAllMarketSubMsg, ); if (!confirmed || !context.mounted) return; final err = await notifier.closeMarket(position); if (!context.mounted) return; final l10n4 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n4) ?? l10n4.closeAllMarketSuccess, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } void _showCloseSheet( BuildContext context, WidgetRef ref, FuturesNotifier notifier) { if (!_requireLogin(context, ref)) return; 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: (_) => _ClosePositionSheet( position: position, notifier: notifier, symbol: symbol), ); } void _showTpslSheet( BuildContext context, WidgetRef ref, FuturesNotifier notifier) { if (!_requireLogin(context, ref)) return; FocusScope.of(context).unfocus(); final pricePrecision = ref.read(futuresProvider(symbol).select((s) => s.pricePrecision)); showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) => _TpslSheet( position: position, notifier: notifier, symbol: symbol, pricePrecision: pricePrecision), ); } Future _reverse( BuildContext context, WidgetRef ref, FuturesNotifier notifier) async { if (!_requireLogin(context, ref)) return; final isLong = position.side == OrderSide.long; final l10n = AppLocalizations.of(context)!; final confirmed = await _showFuturesConfirm( context, message: l10n.reverseConfirm( isLong ? l10n.longLabel : l10n.shortLabel, isLong ? l10n.openShort : l10n.openLong, ), ); if (!confirmed || !context.mounted) return; final err = await notifier.reversePosition(position); if (!context.mounted) return; final l10n5 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n5) ?? l10n5.reverseSuccess, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } } /// 自定义 checkbox(方形边框,选中时填充品牌色+勾) class _CheckBox extends StatelessWidget { const _CheckBox({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 _DataCol extends StatelessWidget { const _DataCol({ required this.label, required this.value, this.valueColor, this.align = CrossAxisAlignment.start, }); final String label; final String value; final Color? valueColor; final CrossAxisAlignment align; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final textAlign = align == CrossAxisAlignment.end ? TextAlign.right : align == CrossAxisAlignment.center ? TextAlign.center : TextAlign.left; return Expanded( child: Column( crossAxisAlignment: align, children: [ Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: textAlign, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)), Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: textAlign, style: TextStyle( color: valueColor ?? cs.onSurface, fontSize: 12, fontWeight: FontWeight.w500)), ], ), ); } } /// 委托卡片数据行:全宽,label 靠左,value 靠右 class _DataLine extends StatelessWidget { const _DataLine({required this.label, required this.value, this.valueColor}); final String label; final String value; final Color? valueColor; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Row( children: [ Expanded( child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)), ), Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: valueColor ?? cs.onSurface, fontSize: 11, fontWeight: FontWeight.w500)), ], ); } } class _ActionBtn extends StatelessWidget { const _ActionBtn({required this.text, required this.onTap}); final String text; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Expanded( child: SizedBox( height: 28, child: OutlinedButton( onPressed: onTap, style: OutlinedButton.styleFrom( side: BorderSide(color: cs.outline), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), padding: EdgeInsets.zero, ), child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w500)), ), ), ); } } /// 持仓卡片小标签(边框样式,对应原型 .pos-type-tag) class _SmallTag extends StatelessWidget { const _SmallTag({required this.text, this.color}); final String text; final Color? color; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final c = color; if (c != null) { return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: c.withAlpha(isDark ? 45 : 25), borderRadius: BorderRadius.circular(3), ), child: Text(text, style: TextStyle(color: c, fontSize: 9, fontWeight: FontWeight.w500)), ); } return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( border: Border.all(color: cs.outline.withAlpha(80), width: 0.5), borderRadius: BorderRadius.circular(3), ), child: Text( text, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9), ), ); } } class _OrdersList extends ConsumerWidget { const _OrdersList({required this.symbol, required this.orders}); final String symbol; final List orders; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final hasMore = ref.watch(futuresProvider(symbol).select((s) => s.ordersHasMore)); final loadingMore = ref.watch(futuresProvider(symbol).select((s) => s.ordersLoadingMore)); if (orders.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Center( child: Text(AppLocalizations.of(context)!.noOrders, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)), ), ); } final footerCount = (loadingMore || !hasMore) ? 1 : 0; return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: orders.length + footerCount, itemBuilder: (_, i) { if (i < orders.length) { return RepaintBoundary( key: ValueKey('ord_${orders[i].id}'), child: _OrderCard(symbol: symbol, order: orders[i]), ); } if (loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center( child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center( child: Text(AppLocalizations.of(context)!.allLoaded, style: TextStyle(color: cs.onSurface.withAlpha(80), fontSize: 11)), ), ); }, ); } } class _OrderCard extends ConsumerWidget { const _OrderCard({required this.symbol, required this.order}); final String symbol; final FuturesOrder order; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; final notifier = ref.read(futuresProvider(symbol).notifier); final coinSymbol = _baseCoin(order.symbol); // 颜色规则:开多/平空=绿(买方向);开空/平多=红(卖方向) final isOpen = order.isOpenOrder; final isLong = order.side == OrderSide.long; final actionColor = isOpen ? (isLong ? AppColors.rise : AppColors.fall) : (isLong ? AppColors.fall : AppColors.rise); final hasProfit = order.profitPrice != null && order.profitPrice! > 0; final hasLoss = order.lossPrice != null && order.lossPrice! > 0; // 展示用价格:市价/计划市价→"市价",限价/计划限价→原始值 String _fmt(double v) => v == v.truncateToDouble() ? '${v.toInt()}' : v.toString(); final l10n = AppLocalizations.of(context)!; final priceText = (order.type == OrderType.market || order.type == OrderType.conditionalMarket) ? l10n.marketPrice : (order.price > 0 ? _fmt(order.price) : '--'); // 对应原型 .order-card { border-bottom: 6px solid var(--color-bg-page) } return GestureDetector( onTap: () async { notifier.stopPolling(); await context.push('/futures/$symbol/order-detail', extra: order); if (context.mounted) notifier.resumePolling(symbol); }, child: Container( decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Theme.of(context).scaffoldBackgroundColor, width: 6, ), ), ), padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── 标题行(与持仓卡片结构一致)────────────────────────── Row( children: [ // 开多/开空/平多/平空 — 实心色块 chip Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: actionColor, borderRadius: BorderRadius.circular(3), ), child: Text( () { final isLongSide = order.side == OrderSide.long; if (isOpen) return isLongSide ? l10n.openLong : l10n.openShort; return isLongSide ? l10n.closeLong : l10n.closeShort; }(), style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), ), ), const SizedBox(width: 4), // Expanded 包裹币对+标签,撤单按钮固定在最右 Expanded( child: Row( children: [ Flexible( child: Text( order.symbol.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w700), ), ), const SizedBox(width: 4), _SmallTag( text: order.type == OrderType.market ? l10n.marketHint : order.type == OrderType.limit ? l10n.limitLabel : l10n.planOrderLabel), const SizedBox(width: 3), _SmallTag( text: _marginModeLabel(order.marginMode, l10n), color: _marginModeColor(order.marginMode)), const SizedBox(width: 3), Container( padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 1), decoration: BoxDecoration( color: AppColors.leverageGoldBg, borderRadius: BorderRadius.circular(3), ), child: Text( '${order.leverage.toInt()}X', style: const TextStyle( color: AppColors.leverageGold, fontSize: 10, fontWeight: FontWeight.w700), ), ), if (hasProfit) ...[ const SizedBox(width: 4), _TpSlChip( label: l10n.takeProfit, color: AppColors.rise), ], if (hasLoss) ...[ const SizedBox(width: 4), _TpSlChip( label: l10n.stopLoss, color: AppColors.fall), ], ], ), ), const SizedBox(width: 8), // 撤单按钮固定在最右 GestureDetector( onTap: () => _cancelOrder(context, ref, notifier), child: Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all(color: cs.outline), ), child: Text(l10n.cancelOrder, style: TextStyle(color: cs.onSurface, fontSize: 11)), ), ), ], ), const SizedBox(height: 8), // ── 数据块(label 靠左,value 靠右,全宽行)──────────── _DataLine(label: l10n.orderPriceLabel, value: priceText), const SizedBox(height: 3), _DataLine( label: l10n.orderSizeCoin(coinSymbol), value: formatQuantity(order.size)), const SizedBox(height: 3), _DataLine( label: l10n.filledSizeCoin(coinSymbol), value: formatQuantity(order.filledSize)), if (order.triggerPrice > 0) ...[ const SizedBox(height: 3), _DataLine( label: '${l10n.triggerPrice}(USDT)', value: formatPrice(order.triggerPrice)), ], if (hasProfit) ...[ const SizedBox(height: 3), _DataLine( label: l10n.takeProfitPrice, value: formatPrice(order.profitPrice!), valueColor: AppColors.rise), ], if (hasLoss) ...[ const SizedBox(height: 3), _DataLine( label: l10n.stopLossPrice, value: formatPrice(order.lossPrice!), valueColor: AppColors.fall), ], ], ), )); // GestureDetector + Container } Future _cancelOrder( BuildContext context, WidgetRef ref, FuturesNotifier notifier) async { if (!_requireLogin(context, ref)) return; final err = await notifier.cancelOrder(order); if (!context.mounted) return; final l10n6 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n6) ?? l10n6.cancelOrderSuccess, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } } /// 止盈止损指示小标签(行内 chip) class _TpSlChip extends StatelessWidget { const _TpSlChip({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: color.withAlpha(28), borderRadius: BorderRadius.circular(3), border: Border.all(color: color.withAlpha(120), width: 0.5), ), child: Text( label, style: TextStyle(color: color, fontSize: 9, fontWeight: FontWeight.w600), ), ); } } class _TpslSheet extends ConsumerStatefulWidget { const _TpslSheet( {required this.position, required this.notifier, required this.symbol, required this.pricePrecision}); final FuturesPosition position; final FuturesNotifier notifier; final String symbol; final int pricePrecision; @override ConsumerState<_TpslSheet> createState() => _TpslSheetState(); } class _TpslSheetState extends ConsumerState<_TpslSheet> { late final TextEditingController _tpController; late final TextEditingController _slController; bool _tpEnabled = true; bool _slEnabled = true; @override void initState() { super.initState(); final pos = widget.position; final p = widget.pricePrecision; _tpController = TextEditingController( text: pos.profitPrice != null && pos.profitPrice! > 0 ? pos.profitPrice!.toStringAsFixed(p) : ''); _slController = TextEditingController( text: pos.lossPrice != null && pos.lossPrice! > 0 ? pos.lossPrice!.toStringAsFixed(p) : ''); _tpEnabled = pos.profitPrice != null && pos.profitPrice! > 0 ? true : true; _slEnabled = pos.lossPrice != null && pos.lossPrice! > 0 ? true : true; } @override void dispose() { _tpController.dispose(); _slController.dispose(); super.dispose(); } double get _entryPrice => widget.position.entryPrice; double get _availableSize => widget.position.availableSize; bool get _isLong => widget.position.side == OrderSide.long; /// 预估盈利(止盈触发时) double? _estimatedProfit() { final tp = double.tryParse(_tpController.text); if (tp == null || tp <= 0) return null; return _isLong ? (tp - _entryPrice) * _availableSize : (_entryPrice - tp) * _availableSize; } /// 预估亏损(止损触发时) double? _estimatedLoss() { final sl = double.tryParse(_slController.text); if (sl == null || sl <= 0) return null; return _isLong ? (sl - _entryPrice) * _availableSize : (_entryPrice - sl) * _availableSize; } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; // 标记价格取仓位自身的 markPrice(仓位持有的币种),而非当前切换到的币对 final markPrice = widget.position.markPrice; final coinPrecision = ref .watch(futuresProvider(widget.symbol).select((s) => s.coinPrecision)); final pricePrecision = widget.pricePrecision; final l10n = AppLocalizations.of(context)!; final coinSymbol = _baseCoin(widget.position.symbol); final isLong = _isLong; final position = widget.position; final sideLabel = isLong ? l10n.longHeadLabel : l10n.shortHeadLabel; final leverageLabel = '${position.leverage.toInt()}X'; final coinName = coinSymbol; final profit = _estimatedProfit(); final loss = _estimatedLoss(); return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── 头部 ── Row( children: [ const SizedBox(width: 40), Expanded( child: Column( children: [ const SizedBox(height: 16), Text(l10n.takeProfitStopLoss, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(height: 2), Text( '${coinName}${l10n.perpetual} $sideLabel $leverageLabel ${_marginModeLabel(position.marginMode, l10n)}', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12), ), const SizedBox(height: 4), ], ), ), SizedBox( width: 40, child: IconButton( onPressed: () => Navigator.pop(context), icon: Icon(Icons.close, color: cs.onSurface.withAlpha(153), size: 20), ), ), ], ), // ── 开仓均价 | 标记价格 ── Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${l10n.openAvgPrice}(USDT)', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(formatPrice(_entryPrice), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('${l10n.markLabel}(USDT)', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 11)), const SizedBox(height: 2), Text(formatPrice(markPrice > 0 ? markPrice : _entryPrice), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)), ], ), ), ], ), ), // ── 复选框行 ── Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ _TpslCheckbox( label: l10n.setTakeProfit, value: _tpEnabled, onChanged: (v) => setState(() => _tpEnabled = v), ), const SizedBox(width: 24), _TpslCheckbox( label: l10n.setStopLoss, value: _slEnabled, onChanged: (v) => setState(() => _slEnabled = v), ), ], ), ), const SizedBox(height: 14), // ── 止盈输入 ── if (_tpEnabled) ...[ _TpslInput( controller: _tpController, hint: l10n.tpTriggerPrice, pricePrecision: pricePrecision, onLatest: () { final price = markPrice > 0 ? markPrice : _entryPrice; final text = price.toStringAsFixed(pricePrecision); _tpController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); setState(() {}); }, onChanged: (_) => setState(() {}), ), const SizedBox(height: 10), ], // ── 止损输入 ── if (_slEnabled) ...[ _TpslInput( controller: _slController, hint: l10n.slTriggerPrice, pricePrecision: pricePrecision, onLatest: () { final price = markPrice > 0 ? markPrice : _entryPrice; final text = price.toStringAsFixed(pricePrecision); _slController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); setState(() {}); }, onChanged: (_) => setState(() {}), ), const SizedBox(height: 10), ], // ── 数据行 ── Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 4), child: Column( children: [ _TpslDataRow( label: l10n.closeableSizeCoin(coinSymbol), value: _availableSize > 0 ? formatAmount(_availableSize, decimals: coinPrecision) : '--', valueColor: cs.onSurface, ), const SizedBox(height: 8), _TpslDataRow( label: l10n.estProfit, value: profit != null ? '${profit >= 0 ? '+' : ''}${formatPrice(profit)}' : '0.00', valueColor: profit != null && profit >= 0 ? AppColors.rise : AppColors.fall, ), const SizedBox(height: 8), _TpslDataRow( label: l10n.estLoss, value: loss != null ? '${loss >= 0 ? '+' : ''}${formatPrice(loss)}' : '0.00', valueColor: loss != null && loss < 0 ? AppColors.fall : AppColors.rise, ), ], ), ), const SizedBox(height: 16), // ── 确定按钮 ── Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), child: SizedBox( width: double.infinity, height: 48, child: ElevatedButton( onPressed: () => _submit(context), style: ElevatedButton.styleFrom( backgroundColor: cs.inverseSurface, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), child: Text(l10n.confirmLabel, style: TextStyle( color: cs.onInverseSurface, fontSize: 15, fontWeight: FontWeight.w600)), ), ), ), ], ), ); } Future _submit(BuildContext context) async { final tp = _tpEnabled ? double.tryParse(_tpController.text) : null; final sl = _slEnabled ? double.tryParse(_slController.text) : null; if (tp == null && sl == null) { Navigator.pop(context); return; } final err = await widget.notifier.setPositionTpsl( widget.position, profitPrice: tp, lossPrice: sl, ); if (!context.mounted) return; Navigator.pop(context); final l10n7 = AppLocalizations.of(context)!; showTopToast(context, message: resolveProviderError(err, l10n7) ?? l10n7.tpslSuccess, backgroundColor: err != null ? AppColors.fall : AppColors.rise); } } class _TpslCheckbox extends StatelessWidget { const _TpslCheckbox( {required this.label, required this.value, required this.onChanged}); final String label; final bool value; final ValueChanged onChanged; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; return GestureDetector( onTap: () => onChanged(!value), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 16, height: 16, decoration: BoxDecoration( color: value ? AppColors.brand : (isDark ? Colors.white : Colors.transparent), borderRadius: BorderRadius.circular(3), border: Border.all( color: value ? AppColors.brand : cs.onSurface.withAlpha(220), width: 1.5, ), ), child: value ? const Icon(Icons.check, size: 12, color: Colors.black) : null, ), const SizedBox(width: 6), Text(label, style: TextStyle(color: cs.onSurface, fontSize: 13)), ], ), ); } } class _TpslInput extends StatefulWidget { const _TpslInput({ required this.controller, required this.hint, required this.pricePrecision, required this.onLatest, required this.onChanged, }); final TextEditingController controller; final String hint; final int pricePrecision; final VoidCallback onLatest; final ValueChanged onChanged; @override State<_TpslInput> createState() => _TpslInputState(); } class _TpslInputState extends State<_TpslInput> { final _focusNode = FocusNode(); @override void initState() { super.initState(); _focusNode.addListener(() { if (mounted) setState(() {}); }); } @override void dispose() { _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final isFocused = _focusNode.hasFocus; final bgColor = isFocused ? (isDark ? AppColors.darkBgSecondary : Colors.white) : (isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary); final activeBorder = isDark ? AppColors.darkTextPrimary.withAlpha(200) : const Color(0xFF383838); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Expanded( child: Container( height: 44, decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(8), border: Border.all( color: isFocused ? activeBorder : cs.onSurface.withAlpha(40), width: isFocused ? 1.5 : 1, ), ), child: Row( children: [ Padding( padding: const EdgeInsets.only(left: 12), child: Text(widget.hint, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 13)), ), Expanded( child: TextField( controller: widget.controller, focusNode: _focusNode, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ _PrecisionInputFormatter(widget.pricePrecision) ], onChanged: widget.onChanged, textAlign: TextAlign.right, decoration: InputDecoration( border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, filled: false, contentPadding: const EdgeInsets.symmetric(horizontal: 8), ), style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600), ), ), Padding( padding: const EdgeInsets.only(right: 12), child: Text('USDT', style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 12)), ), ], ), ), ), const SizedBox(width: 8), GestureDetector( onTap: widget.onLatest, child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: cs.inverseSurface, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text(AppLocalizations.of(context)!.latestLabel, style: TextStyle( color: cs.onInverseSurface, fontSize: 13, fontWeight: FontWeight.w500)), ), ), ], ), ); } } class _TpslDataRow extends StatelessWidget { const _TpslDataRow( {required this.label, required this.value, required this.valueColor}); final String label; final String value; final Color valueColor; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)), Text(value, style: TextStyle( color: valueColor, fontSize: 12, fontWeight: FontWeight.w500)), ], ); } } class _ClosePositionSheet extends ConsumerStatefulWidget { const _ClosePositionSheet( {required this.position, required this.notifier, required this.symbol}); final FuturesPosition position; final FuturesNotifier notifier; final String symbol; @override ConsumerState<_ClosePositionSheet> createState() => _ClosePositionSheetState(); } class _ClosePositionSheetState extends ConsumerState<_ClosePositionSheet> { final _priceController = TextEditingController(); final _volumeController = TextEditingController(); final _priceFocusNode = FocusNode(); final _volumeFocusNode = FocusNode(); bool _isMarket = false; // false=限价, true=市价 double _percent = 0; String _baseCoin(String sym) { if (sym.contains('/')) return sym.split('/').first; return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); } @override void initState() { super.initState(); _priceFocusNode.addListener(() { if (mounted) setState(() {}); }); _volumeFocusNode.addListener(() { if (mounted) setState(() {}); }); } @override void dispose() { _priceController.dispose(); _volumeController.dispose(); _priceFocusNode.dispose(); _volumeFocusNode.dispose(); super.dispose(); } void _onPercentChanged(double p) { setState(() => _percent = p); final avail = widget.position.availableSize; final vol = avail * p; if (vol <= 0) { _volumeController.clear(); } else { final precision = ref .read(futuresProvider(widget.symbol).select((s) => s.coinPrecision)); _volumeController.text = vol.toStringAsFixed(precision); } } void _syncPercentFromVolume() { final avail = widget.position.availableSize; if (avail <= 0) return; final vol = double.tryParse(_volumeController.text.replaceAll(',', '')) ?? 0; setState(() => _percent = (vol / avail).clamp(0.0, 1.0)); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final inputUnfocusedBg = isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary; final inputFocusedBg = isDark ? AppColors.darkBgSecondary : Colors.white; final inputActiveBorder = isDark ? AppColors.darkTextPrimary.withAlpha(200) : const Color(0xFF383838); final coinPrecision = ref .watch(futuresProvider(widget.symbol).select((s) => s.coinPrecision)); final pricePrecision = ref .watch(futuresProvider(widget.symbol).select((s) => s.pricePrecision)); final pos = widget.position; final l10n = AppLocalizations.of(context)!; final isLong = pos.side == OrderSide.long; final sideLabel = isLong ? l10n.longHeadLabel : l10n.shortHeadLabel; final leverage = pos.leverage.toInt(); final availSize = pos.availableSize; final coinSymbol = _baseCoin(pos.symbol); // 预计盈亏:限价用输入价格,市价用标记价格估算 double? estPnl; final closeVol = double.tryParse(_volumeController.text.replaceAll(',', '')) ?? 0; if (closeVol > 0) { if (_isMarket) { // 市价:以标记价格估算盈亏 final diff = isLong ? (pos.markPrice - pos.entryPrice) : (pos.entryPrice - pos.markPrice); estPnl = diff * closeVol; } else { final price = double.tryParse(_priceController.text); if (price != null && price > 0) { // 多头平仓:卖出价 - 开仓价;空头平仓:开仓价 - 买入价 final diff = isLong ? (price - pos.entryPrice) : (pos.entryPrice - price); estPnl = diff * closeVol; } } } return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── 标题行 ──────────────────────────────────────────── SizedBox( width: double.infinity, child: Stack( alignment: Alignment.center, children: [ Text(l10n.closePositionBtn, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600)), Positioned( right: 0, child: GestureDetector( onTap: () => Navigator.pop(context), child: Icon(Icons.close, size: 20, color: cs.onSurface.withAlpha(153)), ), ), ], ), ), const SizedBox(height: 4), Text( '${pos.symbol} ${l10n.perpetual} $sideLabel ${leverage}X ${_marginModeLabel(pos.marginMode, l10n)}', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12), ), const SizedBox(height: 16), // ── 开仓均价 / 标记价格(带边框盒子,对应原型设计)──── Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${l10n.openAvgPrice}(USDT)', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), const SizedBox(height: 2), Text(formatAmount(pos.entryPrice), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500)), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('${l10n.markLabel}(USDT)', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 11)), const SizedBox(height: 2), GestureDetector( onTap: _isMarket ? null : () { final text = pos.markPrice.toStringAsFixed(pricePrecision); _priceController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); setState(() {}); }, child: Text(formatAmount(pos.markPrice), style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500)), ), ], ), ], ), const SizedBox(height: 12), // ── 价格输入 + 限价/市价切换 ───────────────────────── Row( children: [ Expanded( child: Container( height: 46, decoration: BoxDecoration( color: _isMarket ? inputUnfocusedBg : (_priceFocusNode.hasFocus ? inputFocusedBg : inputUnfocusedBg), borderRadius: BorderRadius.circular(8), border: !_isMarket && _priceFocusNode.hasFocus ? Border.all(color: inputActiveBorder, width: 1.5) : Border.all( color: cs.onSurface.withAlpha(40), width: 1), ), child: TextField( controller: _priceController, focusNode: _priceFocusNode, enabled: !_isMarket, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [_PrecisionInputFormatter(pricePrecision)], style: TextStyle(color: cs.onSurface, fontSize: 14), onChanged: (_) => setState(() {}), decoration: InputDecoration( hintText: _isMarket ? l10n.marketHint : l10n.pricePlaceholder, hintStyle: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 14), suffixText: 'USDT', suffixStyle: TextStyle( color: cs.onSurface.withAlpha(100), fontSize: 12), filled: false, contentPadding: const EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, disabledBorder: InputBorder.none, ), ), ), ), const SizedBox(width: 8), GestureDetector( onTap: () => setState(() { _isMarket = !_isMarket; _priceController.clear(); }), child: Container( height: 46, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: AppColors.brand, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( _isMarket ? l10n.marketHint : l10n.limitLabel, style: TextStyle( color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ), ], ), if (!_isMarket) ...[ const SizedBox(height: 4), Align( alignment: Alignment.centerLeft, child: Text( isLong ? l10n.closePositionMsgLong : l10n.closePositionMsgShort, style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 11), ), ), ], const SizedBox(height: 8), // ── 平仓数量输入(带币种后缀)──────────────────────── Container( height: 46, decoration: BoxDecoration( color: _volumeFocusNode.hasFocus ? inputFocusedBg : inputUnfocusedBg, borderRadius: BorderRadius.circular(8), border: _volumeFocusNode.hasFocus ? Border.all(color: inputActiveBorder, width: 1.5) : Border.all(color: cs.onSurface.withAlpha(40), width: 1), ), child: TextField( controller: _volumeController, focusNode: _volumeFocusNode, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [_PrecisionInputFormatter(coinPrecision)], style: TextStyle(color: cs.onSurface, fontSize: 14), onChanged: (_) => _syncPercentFromVolume(), decoration: InputDecoration( hintText: l10n.enterCloseVolume, hintStyle: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 14), suffixText: coinSymbol, suffixStyle: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 12), filled: false, contentPadding: const EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, ), ), ), const SizedBox(height: 12), // ── 滑动条 ────────────────────────────────────────── _PercentSlider( percent: _percent, onChanged: _onPercentChanged, ), const SizedBox(height: 12), // ── 可平量 / 预计盈亏 ───────────────────────────────── Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.closeableSizeCoin(coinSymbol), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), Text(formatAmount(availSize, decimals: coinPrecision), style: TextStyle(color: cs.onSurface, fontSize: 12)), ], ), const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.estPnlLabel, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 12)), Text( estPnl == null ? '-- USDT' : '${estPnl >= 0 ? '+' : ''}${formatAmount(estPnl)} USDT', style: TextStyle( color: estPnl == null ? cs.onSurface.withAlpha(153) : (estPnl >= 0 ? AppColors.rise : AppColors.fall), fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), const SizedBox(height: 16), // ── 确定按钮 ────────────────────────────────────────── SizedBox( width: double.infinity, height: 52, child: ElevatedButton( onPressed: () => _close(context), style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(26), ), ), child: Text( l10n.confirmLabel, style: TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600), ), ), ), ], ), ); } Future _close(BuildContext context) async { final volume = double.tryParse(_volumeController.text.replaceAll(',', '')); // 数量为空或0不允许提交 if (volume == null || volume <= 0) { showTopToast(context, message: AppLocalizations.of(context)!.enterCloseVolume, backgroundColor: AppColors.fall); return; } String? err; if (_isMarket) { err = await widget.notifier.closeMarket(widget.position, volume: volume); } else { final price = double.tryParse(_priceController.text) ?? 0; // 限价单必须输入价格,未填则提示且不关闭弹窗 if (price <= 0) { showTopToast(context, message: AppLocalizations.of(context)!.enterLimitPrice, backgroundColor: AppColors.fall); return; } err = await widget.notifier .closeLimit(widget.position, price, volume: volume); } if (!context.mounted) return; final l10n8 = AppLocalizations.of(context)!; if (err != null) { showTopToast(context, message: resolveProviderError(err, l10n8) ?? err, backgroundColor: AppColors.fall); return; } Navigator.pop(context); showTopToast(context, message: l10n8.closeOrderSubmitted, backgroundColor: AppColors.rise); } } class _AssetsPanel extends StatelessWidget { const _AssetsPanel({required this.info}); final FuturesAccountInfo info; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(24, 12, 24, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(AppLocalizations.of(context)!.contractAccountUsdt, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)), const SizedBox(height: 4), Text( formatAmount(info.totalBalance), style: TextStyle( color: cs.onSurface, fontSize: 22, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 10), Row( children: [ _AssetItem( label: AppLocalizations.of(context)!.availableMargin, value: formatAmount(info.availableMargin), align: CrossAxisAlignment.start), _AssetItem( label: AppLocalizations.of(context)!.usedMargin, value: formatAmount(info.usedMargin), align: CrossAxisAlignment.center), _AssetItem( label: AppLocalizations.of(context)!.unrealizedPnl, value: '${info.unrealizedPnl >= 0 ? '+' : ''}${formatAmount(info.unrealizedPnl)}', valueColor: info.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall, align: CrossAxisAlignment.end, ), ], ), ], ), ); } } class _AssetItem extends StatelessWidget { const _AssetItem({ required this.label, required this.value, this.valueColor, this.align = CrossAxisAlignment.start, }); final String label; final String value; final Color? valueColor; final CrossAxisAlignment align; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final textAlign = align == CrossAxisAlignment.end ? TextAlign.right : align == CrossAxisAlignment.center ? TextAlign.center : TextAlign.left; return Expanded( child: Column( crossAxisAlignment: align, children: [ Text(label, textAlign: textAlign, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 10)), const SizedBox(height: 2), Text(value, textAlign: textAlign, style: TextStyle( color: valueColor ?? cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500)), ], ), ); } } double _toDouble(dynamic v) { if (v == null) return 0.0; if (v is num) return v.toDouble(); return double.tryParse(v.toString()) ?? 0.0; } /// AppBar 顶部"现货 / 永续合约"切换 Tab /// 与 spot_screen.dart 中的 _SegmentedTabHeader 保持一致样式 class _SpotFuturesTabHeader extends StatelessWidget { const _SpotFuturesTabHeader({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, ), ], ), ), ); }), ); } } /// 精度输入格式化器 /// [decimals] = 0 时只允许正整数;> 0 时允许最多 [decimals] 位小数 class _PrecisionInputFormatter extends TextInputFormatter { const _PrecisionInputFormatter(this.decimals); final int decimals; @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue) { final text = newValue.text; if (text.isEmpty) return newValue; if (decimals == 0) { // 只允许正整数 if (!RegExp(r'^\d+$').hasMatch(text)) return oldValue; return newValue; } // 允许小数:最多 [decimals] 位 final pattern = RegExp(r'^\d+\.?\d{0,' + decimals.toString() + r'}$'); if (!pattern.hasMatch(text)) return oldValue; // 不允许以多个小数点开头 if (text.indexOf('.') != text.lastIndexOf('.')) return oldValue; return newValue; } } // ══════════════════════════════════════════════════════════════════════════════ // 仓位分享相关 widget // ══════════════════════════════════════════════════════════════════════════════ /// 分享底部弹窗:预览分享卡片 + 操作按钮(供资产页复用) class SharePositionSheet extends ConsumerStatefulWidget { const SharePositionSheet({super.key, required this.position}); final FuturesPosition position; @override ConsumerState createState() => _SharePositionSheetState(); } class _SharePositionSheetState extends ConsumerState { final _cardKey = GlobalKey(); bool _sharing = false; bool _saving = false; String? _inviteCode; String? _inviteUrl; @override void initState() { super.initState(); _loadInviteInfo(); } Future _loadInviteInfo() async { try { final dio = ref.read(dioClientProvider); final data = await AuthService(dio).getMyInfo(); final prefix = data['promotionPrefix']?.toString() ?? ''; final code = data['promotionCode']?.toString() ?? ''; final url = (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null; if (mounted) { setState(() { _inviteCode = code.isNotEmpty ? code : null; _inviteUrl = url; }); } } catch (e) { print('[ShareCard] _loadInviteInfo error: $e'); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; final pos = widget.position; final pnlPositive = pos.unrealizedPnl >= 0; final l10n = AppLocalizations.of(context)!; return Container( decoration: BoxDecoration( color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary, borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), ), padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 拖拽指示条 Container( width: 36, height: 4, decoration: BoxDecoration( color: cs.onSurface.withAlpha(60), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 16), // 分享卡片预览 RepaintBoundary( key: _cardKey, child: _PositionShareCard( position: pos, inviteCode: _inviteCode, inviteUrl: _inviteUrl, ), ), const SizedBox(height: 24), // 操作按钮行:取消 | 保存海报 | 分享 Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), child: Text(l10n.cancelLabel, style: TextStyle(color: cs.onSurface, fontSize: 14)), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: _saving ? null : () => _doSave(context), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), ), child: _saving ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: cs.onSurface.withAlpha(153)), ) : Text(l10n.savePoster, style: TextStyle(color: cs.onSurface, fontSize: 14)), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton( onPressed: _sharing ? null : () => _doShare(context), style: ElevatedButton.styleFrom( backgroundColor: pnlPositive ? AppColors.rise : AppColors.fall, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: _sharing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : Text(l10n.shareLabel, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), ), ), ], ), ], ), ); } Future _renderCard() async { final boundary = _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null; final image = await boundary.toImage(pixelRatio: 3.0); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); return byteData?.buffer.asUint8List(); } Future _doSave(BuildContext context) async { setState(() => _saving = true); try { final bytes = await _renderCard(); if (bytes == null) return; // 申请权限(首次弹系统弹窗;已拒绝则 false,但仍尝试写入让 GalException 决定) await Gal.requestAccess(); await Gal.putImageBytes( bytes, name: 'position_share_${DateTime.now().millisecondsSinceEpoch}', ); if (!context.mounted) return; showTopToast(context, message: AppLocalizations.of(context)!.saveSuccess, backgroundColor: AppColors.rise); } on GalException catch (e) { if (!context.mounted) return; // accessDenied 时引导用户去设置开启权限 final l10n = AppLocalizations.of(context)!; if (e.type == GalExceptionType.accessDenied) { showTopToast(context, message: l10n.photoPermissionDenied, backgroundColor: AppColors.fall); } else { showTopToast(context, message: l10n.saveFailed, backgroundColor: AppColors.fall); } } catch (e) { if (context.mounted) { showTopToast(context, message: AppLocalizations.of(context)!.saveFailed, backgroundColor: AppColors.fall); } } finally { if (mounted) setState(() => _saving = false); } } Future _doShare(BuildContext context) async { setState(() => _sharing = true); try { final bytes = await _renderCard(); if (bytes == null) return; final tmpDir = await getTemporaryDirectory(); final file = File( '${tmpDir.path}/position_share_${DateTime.now().millisecondsSinceEpoch}.png'); await file.writeAsBytes(bytes); if (!context.mounted) return; Navigator.of(context).pop(); await Share.shareXFiles( [XFile(file.path, mimeType: 'image/png')], subject: AppLocalizations.of(context)!.myFuturesPosition, ); } catch (e) { if (context.mounted) { showTopToast(context, message: AppLocalizations.of(context)!.shareFailed, backgroundColor: AppColors.fall); } } finally { if (mounted) setState(() => _sharing = false); } } } /// 分享卡片内容 class _PositionShareCard extends StatelessWidget { const _PositionShareCard({ required this.position, this.inviteCode, this.inviteUrl, }); final FuturesPosition position; final String? inviteCode; final String? inviteUrl; String _baseCoin(String sym) { if (sym.contains('/')) return sym.split('/').first; return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), ''); } String _formatNow() { final t = DateTime.now(); final mo = t.month.toString().padLeft(2, '0'); final d = t.day.toString().padLeft(2, '0'); final h = t.hour.toString().padLeft(2, '0'); final mi = t.minute.toString().padLeft(2, '0'); final s = t.second.toString().padLeft(2, '0'); return '${t.year}-$mo-$d $h:$mi:$s'; } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final pos = position; final isLong = pos.side == OrderSide.long; final sideColor = isLong ? AppColors.rise : AppColors.fall; final pnlPositive = pos.unrealizedPnl >= 0; final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall; final coinSymbol = _baseCoin(pos.symbol); final roeStr = '${pnlPositive ? '+' : ''}${formatAmount(pos.roe)}%'; final qrData = inviteUrl; // 主题色变量 final bgColors = isDark ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)] : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)]; final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E); final textSecondary = isDark ? Colors.white.withAlpha(120) : const Color(0xFF1A1F2E).withAlpha(120); final textMuted = isDark ? Colors.white.withAlpha(80) : const Color(0xFF1A1F2E).withAlpha(80); final borderColor = isDark ? Colors.white.withAlpha(40) : const Color(0xFF1A1F2E).withAlpha(30); final qrFgColor = isDark ? Colors.white : Colors.black; final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white; return Container( width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: bgColors, ), borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // LOGO + 品牌名 Row( children: [ Image.asset( 'assets/images/app_icon.png', height: 28, width: 28, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), const SizedBox(width: 8), Text( 'iBit', style: TextStyle( color: textPrimary, fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: 0.5), ), ], ), const SizedBox(height: 14), // 币对 + 永续 tag Row( children: [ Text( '${coinSymbol}USDT', style: TextStyle( color: textPrimary, fontSize: 22, fontWeight: FontWeight.w800), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: const Color(0xFFFFAB00), borderRadius: BorderRadius.circular(4), ), child: Text(AppLocalizations.of(context)!.perpetual, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700)), ), ], ), const SizedBox(height: 4), // 方向 + 杠杆 Text( '${isLong ? AppLocalizations.of(context)!.openLong : AppLocalizations.of(context)!.openShort} ${pos.leverage.toInt()}X', style: TextStyle( color: sideColor, fontSize: 15, fontWeight: FontWeight.w700), ), const SizedBox(height: 14), // 收益率(大字) Text(AppLocalizations.of(context)!.returnRate, style: TextStyle(color: textSecondary, fontSize: 12)), const SizedBox(height: 4), Text(roeStr, style: TextStyle( color: pnlColor, fontSize: 36, fontWeight: FontWeight.w800, letterSpacing: -0.5)), const SizedBox(height: 16), // 最新价 + 开仓均价 Row( children: [ Expanded( child: _ShareDataItem( label: AppLocalizations.of(context)!.latestPriceFull, value: formatAmount(pos.markPrice), textPrimary: textPrimary, textSecondary: textSecondary, ), ), Expanded( child: _ShareDataItem( label: AppLocalizations.of(context)!.openAvgPrice, value: formatAmount(pos.entryPrice), align: CrossAxisAlignment.end, textPrimary: textPrimary, textSecondary: textSecondary, ), ), ], ), const SizedBox(height: 10), // 时间 Text(_formatNow(), style: TextStyle(color: textMuted, fontSize: 11)), const SizedBox(height: 14), // 分隔线 Divider(color: borderColor, height: 1), const SizedBox(height: 14), // 邀请码 + 二维码 Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (inviteCode != null) RichText( text: TextSpan( style: const TextStyle(fontSize: 15), children: [ TextSpan( text: AppLocalizations.of(context)! .inviteCodeLabel, style: TextStyle(color: textSecondary), ), TextSpan( text: inviteCode!, style: const TextStyle( color: AppColors.brand, fontWeight: FontWeight.w700), ), ], ), ), const SizedBox(height: 4), Text(AppLocalizations.of(context)!.registerAndEarnRebate, style: TextStyle(color: textMuted, fontSize: 12)), ], ), ), Container( decoration: BoxDecoration( border: Border.all(color: borderColor, width: 1), borderRadius: BorderRadius.circular(6), ), padding: const EdgeInsets.all(4), child: qrData != null ? QrImageView( data: qrData, version: QrVersions.auto, size: 80, eyeStyle: QrEyeStyle( eyeShape: QrEyeShape.square, color: qrFgColor, ), dataModuleStyle: QrDataModuleStyle( dataModuleShape: QrDataModuleShape.square, color: qrFgColor, ), backgroundColor: qrBgColor, errorCorrectionLevel: QrErrorCorrectLevel.M, ) : const SizedBox(width: 80, height: 80), ), ], ), ], ), ), ); } } class _ShareDataItem extends StatelessWidget { const _ShareDataItem({ required this.label, required this.value, required this.textPrimary, required this.textSecondary, this.align = CrossAxisAlignment.start, }); final String label; final String value; final Color textPrimary; final Color textSecondary; final CrossAxisAlignment align; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: align, children: [ Text(label, style: TextStyle(color: textSecondary, fontSize: 11)), const SizedBox(height: 2), Text(value, style: TextStyle( color: textPrimary, fontSize: 13, fontWeight: FontWeight.w600)), ], ); } } // ── 合约骨架屏 ────────────────────────────────────────────── /// 持仓/委托 Tab 列表骨架:[rows] 行卡片占位 class _TabShimmer extends StatelessWidget { const _TabShimmer({required this.rows}); final int rows; @override Widget build(BuildContext context) { return AppShimmer( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), child: Column( children: List.generate( rows, (i) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行:币对 + 方向标签 + 浮盈 Row(children: [ shimmerBox(80, 14), const SizedBox(width: 8), shimmerBox(44, 18, radius: 4), const Spacer(), shimmerBox(70, 14), ]), const SizedBox(height: 10), // 数据行 Row(children: [ Expanded(child: shimmerBox(double.infinity, 11)), const SizedBox(width: 12), Expanded(child: shimmerBox(double.infinity, 11)), const SizedBox(width: 12), Expanded(child: shimmerBox(double.infinity, 11)), ]), const SizedBox(height: 8), Row(children: [ Expanded(child: shimmerBox(double.infinity, 11)), const SizedBox(width: 12), Expanded(child: shimmerBox(double.infinity, 11)), const SizedBox(width: 12), Expanded(child: shimmerBox(double.infinity, 11)), ]), const SizedBox(height: 10), // 操作按钮行 Row(children: [ shimmerBox(56, 24, radius: 4), const SizedBox(width: 8), shimmerBox(56, 24, radius: 4), const SizedBox(width: 8), shimmerBox(56, 24, radius: 4), ]), ], ), ), )), ), ), ); } } /// 资产面板骨架 class _AssetShimmer extends StatelessWidget { const _AssetShimmer(); @override Widget build(BuildContext context) { return AppShimmer( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(100, 12), const SizedBox(height: 8), shimmerBox(160, 26), const SizedBox(height: 14), Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 11), const SizedBox(height: 6), shimmerBox(80, 14), ])), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 11), const SizedBox(height: 6), shimmerBox(80, 14), ])), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ shimmerBox(60, 11), const SizedBox(height: 6), shimmerBox(80, 14), ])), ]), ], ), ), ); } } class _FuturesShimmer extends StatelessWidget { const _FuturesShimmer(); @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), // 按钮 Row(children: [ Expanded(child: shimmerFill(40, radius: 8)), const SizedBox(width: 8), Expanded(child: shimmerFill(40, 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), shimmerBox(40, 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(60, 28, radius: 6), const SizedBox(width: 8), shimmerBox(60, 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> { double? _lastHeight; @override Widget build(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final rb = context.findRenderObject() as RenderBox?; if (rb == null || !rb.hasSize) return; final h = rb.size.height; if (h != _lastHeight) { _lastHeight = h; widget.onHeight(h); } }); return widget.child; } }