| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- import 'package:cached_network_image/cached_network_image.dart';
- import 'package:flutter/material.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/number_format.dart';
- import '../../../core/utils/symbol_display.dart';
- import '../../../data/models/home/market_ticker.dart';
- import '../../../providers/market_provider.dart';
- /// 交易对选择器中的 Tab 类型
- enum SymbolPickerTab { futures, spot }
- /// 通用币对选择底部弹窗(合约 / 现货双 Tab)
- ///
- /// 数据直接来源于 [marketProvider](已批量订阅所有 WS ticker),
- /// 打开时无需额外 API 请求,实时价格自动刷新。
- ///
- /// 用法:
- /// ```dart
- /// showModalBottomSheet(
- /// isScrollControlled: true,
- /// builder: (_) => SymbolPickerSheet(
- /// currentSymbol: 'BTCUSDT',
- /// initialTab: SymbolPickerTab.spot,
- /// onSelected: (sym) { /* 合约选中 */ },
- /// onSpotSelected: (sym) { /* 现货选中 */ },
- /// ),
- /// );
- /// ```
- class SymbolPickerSheet extends ConsumerStatefulWidget {
- const SymbolPickerSheet({
- super.key,
- required this.onSelected,
- this.onSpotSelected,
- this.currentSymbol,
- this.initialTab = SymbolPickerTab.futures,
- this.visibleTabs,
- this.title,
- });
- /// 当前选中的 symbol(大写无斜线,如 "BTCUSDT"),用于高亮当前行
- final String? currentSymbol;
- /// 合约 tab 选中回调,返回大写无斜线格式的 symbol
- final ValueChanged<String> onSelected;
- /// 现货 tab 选中回调;若为 null 则复用 [onSelected]
- final ValueChanged<String>? onSpotSelected;
- /// 初始展示的 Tab,默认合约
- final SymbolPickerTab initialTab;
- /// 允许展示的 Tab 列表;null 或空表示全部展示
- final List<SymbolPickerTab>? visibleTabs;
- /// 弹窗标题(已废弃,Tab 本身即标题,保留为可选兼容参数)
- final String? title;
- @override
- ConsumerState<SymbolPickerSheet> createState() => _SymbolPickerSheetState();
- }
- class _SymbolPickerSheetState extends ConsumerState<SymbolPickerSheet> {
- final _searchCtrl = TextEditingController();
- String _query = '';
- late SymbolPickerTab _tab;
- // 本地 ticker 快照,通过节流 setState 刷新,保证所有 Riverpod
- // 读取发生在 build 阶段(而非 layout 阶段),避免 layout/semantics 断言崩溃。
- Map<String, MarketTicker> _futuresTickerBySymbol = {};
- Map<String, MarketTicker> _spotTickerBySymbol = {};
- bool _pendingRefresh = false;
- @override
- void initState() {
- super.initState();
- _tab = widget.initialTab;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _syncSnapshot();
- // 预加载现货数据
- if (_tab == SymbolPickerTab.spot) {
- ref.read(marketProvider.notifier).loadSpotIfNeeded();
- }
- });
- }
- void _syncSnapshot() {
- if (!mounted) return;
- final mkt = ref.read(marketProvider);
- setState(() {
- _futuresTickerBySymbol = {for (final t in mkt.tickers) t.symbol: t};
- _spotTickerBySymbol = {for (final t in mkt.spotTickers) t.symbol: t};
- });
- }
- void _scheduleRefresh() {
- if (_pendingRefresh) return;
- _pendingRefresh = true;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- _pendingRefresh = false;
- _syncSnapshot();
- });
- }
- @override
- void dispose() {
- _searchCtrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- // 只 watch displayOrder(币对列表结构),价格更新不触发重建
- final futuresSymbols = ref.watch(
- marketProvider.select((s) => s.displayOrder),
- );
- final spotSymbols = ref.watch(
- marketProvider.select((s) => s.spotDisplayOrder),
- );
- // 监听价格变化,节流刷新本地快照
- ref.listen<List<MarketTicker>>(
- marketProvider.select((s) => s.tickers),
- (_, __) => _scheduleRefresh(),
- );
- ref.listen<List<MarketTicker>>(
- marketProvider.select((s) => s.spotTickers),
- (_, __) => _scheduleRefresh(),
- );
- // 当前 tab 的数据
- final allSymbols =
- _tab == SymbolPickerTab.futures ? futuresSymbols : spotSymbols;
- final tickerBySymbol = _tab == SymbolPickerTab.futures
- ? _futuresTickerBySymbol
- : _spotTickerBySymbol;
- List<String> filtered;
- if (_query.isEmpty) {
- filtered = allSymbols;
- } else {
- final q = _query.toLowerCase();
- filtered = allSymbols.where((sym) {
- final t = tickerBySymbol[sym];
- return sym.toLowerCase().contains(q) ||
- (t?.baseAsset.toLowerCase().contains(q) ?? false);
- }).toList();
- }
- return DraggableScrollableSheet(
- initialChildSize: 0.85,
- minChildSize: 0.5,
- maxChildSize: 0.95,
- expand: false,
- builder: (_, scrollCtrl) => Column(
- children: [
- // ── 标题栏 ───────────────────────────────────────
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
- child: Row(
- children: [
- Text(
- l10n.selectTradingPair,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- const Spacer(),
- GestureDetector(
- onTap: () => Navigator.pop(context),
- child: Icon(
- Icons.close,
- color: cs.onSurface.withAlpha(153),
- size: 20,
- ),
- ),
- ],
- ),
- ),
- // ── 合约 / 现货 Tab 切换 ────────────────────────
- _TabBar(
- current: _tab,
- visibleTabs: widget.visibleTabs,
- onChanged: (t) => setState(() {
- _tab = t;
- _query = '';
- _searchCtrl.clear();
- }),
- ),
- // ── 搜索框 ───────────────────────────────────────
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- child: Container(
- height: 36,
- decoration: BoxDecoration(
- color: cs.surfaceContainerHighest.withAlpha(80),
- borderRadius: BorderRadius.circular(8),
- ),
- child: TextField(
- controller: _searchCtrl,
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- onChanged: (v) => setState(() => _query = v.trim()),
- decoration: InputDecoration(
- hintText: l10n.searchHint,
- hintStyle: TextStyle(
- color: cs.onSurface.withAlpha(100), fontSize: 14),
- prefixIcon: Icon(
- Icons.search,
- color: cs.onSurface.withAlpha(100),
- size: 18,
- ),
- border: InputBorder.none,
- contentPadding: const EdgeInsets.symmetric(vertical: 8),
- ),
- ),
- ),
- ),
- // ── 列表头 ───────────────────────────────────────
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
- child: Row(
- children: [
- Expanded(
- child: Text(
- l10n.nameAndVolume,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11,
- ),
- ),
- ),
- Text(
- l10n.latestPriceChange,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11,
- ),
- ),
- ],
- ),
- ),
- const Divider(height: 1),
- // ── 币对列表 ─────────────────────────────────────
- Expanded(
- child: allSymbols.isEmpty
- ? Center(
- child: _tab == SymbolPickerTab.spot
- ? Text(
- l10n.noOrders,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 14,
- ),
- )
- : const CircularProgressIndicator(),
- )
- : ListView.builder(
- controller: scrollCtrl,
- itemCount: filtered.length,
- itemBuilder: (ctx, i) {
- final sym = filtered[i];
- final ticker = tickerBySymbol[sym];
- if (ticker == null) return const SizedBox.shrink();
- return _TickerRow(
- ticker: ticker,
- isSelected: sym == widget.currentSymbol,
- isSpot: _tab == SymbolPickerTab.spot,
- onTap: () {
- if (_tab == SymbolPickerTab.spot) {
- (widget.onSpotSelected ?? widget.onSelected)(sym);
- } else {
- widget.onSelected(sym);
- }
- },
- );
- },
- ),
- ),
- ],
- ),
- );
- }
- }
- // ── Tab 切换栏 ────────────────────────────────────────────
- class _TabBar extends StatelessWidget {
- const _TabBar({
- required this.current,
- required this.onChanged,
- this.visibleTabs,
- });
- final SymbolPickerTab current;
- final ValueChanged<SymbolPickerTab> onChanged;
- final List<SymbolPickerTab>? visibleTabs;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- Widget tab(String label, SymbolPickerTab value) {
- final active = current == value;
- return Expanded(
- child: GestureDetector(
- onTap: () => onChanged(value),
- behavior: HitTestBehavior.opaque,
- child: Container(
- padding: const EdgeInsets.symmetric(vertical: 10),
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: active ? AppColors.brand : Colors.transparent,
- width: 2,
- ),
- ),
- ),
- alignment: Alignment.center,
- child: Text(
- label,
- style: TextStyle(
- fontSize: 14,
- fontWeight: active ? FontWeight.w600 : FontWeight.w400,
- color: active ? cs.onSurface : cs.onSurface.withAlpha(140),
- ),
- ),
- ),
- ),
- );
- }
- final tabs = visibleTabs != null && visibleTabs!.isNotEmpty
- ? visibleTabs!
- : const [SymbolPickerTab.futures, SymbolPickerTab.spot];
- if (tabs.length <= 1) return const SizedBox.shrink();
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Row(
- children: tabs.map((t) {
- final label = t == SymbolPickerTab.futures
- ? l10n.perpetualFutures
- : l10n.spotTab;
- return tab(label, t);
- }).toList(),
- ),
- );
- }
- }
- // ── 单行 Widget(纯 StatelessWidget,无 Riverpod 订阅)──────────
- class _TickerRow extends StatelessWidget {
- const _TickerRow({
- required this.ticker,
- required this.isSelected,
- required this.isSpot,
- required this.onTap,
- });
- final MarketTicker ticker;
- final bool isSelected;
- final bool isSpot;
- final VoidCallback onTap;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- final isUp = ticker.change24h >= 0;
- final changeColor = AppColors.changeColor(ticker.change24h);
- return Material(
- color: Colors.transparent,
- child: InkWell(
- onTap: onTap,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(
- children: [
- // ── 左侧:图标 + 币对名 + 成交量 ──────────────
- Expanded(
- child: Row(
- children: [
- _CoinIcon(icon: ticker.icon, baseAsset: ticker.baseAsset),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Flexible(
- child: Text(
- formatUsdtPairDisplay(ticker.symbol),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: isSelected
- ? AppColors.brand
- : cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- const SizedBox(width: 4),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 4, vertical: 1),
- decoration: BoxDecoration(
- color: cs.outline.withAlpha(40),
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- isSpot ? l10n.spotTab : l10n.perpetual,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 9,
- ),
- ),
- ),
- if (isSelected)
- Padding(
- padding: const EdgeInsets.only(left: 6),
- child: Icon(Icons.check,
- size: 14, color: AppColors.brand),
- ),
- ],
- ),
- const SizedBox(height: 2),
- Text(
- ticker.volume24h > 0
- ? l10n.volumeWithValue(
- formatVolume(ticker.volume24h))
- : l10n.volumeEmpty,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 10,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- // ── 右侧:价格 + 涨跌幅 ───────────────────────
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(
- ticker.lastPrice > 0
- ? (ticker.lastPriceStr != null
- ? formatRawPrice(ticker.lastPriceStr!)
- : formatPrice(ticker.lastPrice))
- : '--',
- style: TextStyle(
- color: ticker.lastPrice > 0
- ? changeColor
- : cs.onSurface.withAlpha(153),
- fontSize: 13,
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- ticker.lastPrice > 0
- ? '${isUp ? '+' : ''}${ticker.change24h.toStringAsFixed(2)}%'
- : '--',
- style: TextStyle(
- color: ticker.lastPrice > 0
- ? changeColor
- : cs.onSurface.withAlpha(100),
- fontSize: 10,
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- );
- }
- }
- // ── 圆形图标(网络图片 / 首字母回退)──────────────────────────
- class _CoinIcon extends StatelessWidget {
- const _CoinIcon({required this.icon, required this.baseAsset});
- final String icon;
- final String baseAsset;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Container(
- width: 32,
- height: 32,
- decoration: BoxDecoration(
- color: cs.surfaceContainerHighest.withAlpha(80),
- shape: BoxShape.circle,
- ),
- child: ClipOval(
- child: icon.isNotEmpty
- ? CachedNetworkImage(
- imageUrl: icon,
- width: 32,
- height: 32,
- fit: BoxFit.cover,
- placeholder: (_, __) => _Fallback(baseAsset: baseAsset),
- errorWidget: (_, __, ___) => _Fallback(baseAsset: baseAsset),
- )
- : _Fallback(baseAsset: baseAsset),
- ),
- );
- }
- }
- class _Fallback extends StatelessWidget {
- const _Fallback({required this.baseAsset});
- final String baseAsset;
- @override
- Widget build(BuildContext context) {
- return Center(
- child: Text(
- baseAsset.isNotEmpty ? baseAsset[0] : '?',
- style: TextStyle(
- color: AppColors.brand,
- fontWeight: FontWeight.w700,
- fontSize: 13,
- ),
- ),
- );
- }
- }
|