symbol_picker_sheet.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import 'package:cached_network_image/cached_network_image.dart';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import '../../../core/l10n/app_localizations.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/utils/number_format.dart';
  7. import '../../../core/utils/symbol_display.dart';
  8. import '../../../data/models/home/market_ticker.dart';
  9. import '../../../providers/market_provider.dart';
  10. /// 交易对选择器中的 Tab 类型
  11. enum SymbolPickerTab { futures, spot }
  12. /// 通用币对选择底部弹窗(合约 / 现货双 Tab)
  13. ///
  14. /// 数据直接来源于 [marketProvider](已批量订阅所有 WS ticker),
  15. /// 打开时无需额外 API 请求,实时价格自动刷新。
  16. ///
  17. /// 用法:
  18. /// ```dart
  19. /// showModalBottomSheet(
  20. /// isScrollControlled: true,
  21. /// builder: (_) => SymbolPickerSheet(
  22. /// currentSymbol: 'BTCUSDT',
  23. /// initialTab: SymbolPickerTab.spot,
  24. /// onSelected: (sym) { /* 合约选中 */ },
  25. /// onSpotSelected: (sym) { /* 现货选中 */ },
  26. /// ),
  27. /// );
  28. /// ```
  29. class SymbolPickerSheet extends ConsumerStatefulWidget {
  30. const SymbolPickerSheet({
  31. super.key,
  32. required this.onSelected,
  33. this.onSpotSelected,
  34. this.currentSymbol,
  35. this.initialTab = SymbolPickerTab.futures,
  36. this.visibleTabs,
  37. this.title,
  38. });
  39. /// 当前选中的 symbol(大写无斜线,如 "BTCUSDT"),用于高亮当前行
  40. final String? currentSymbol;
  41. /// 合约 tab 选中回调,返回大写无斜线格式的 symbol
  42. final ValueChanged<String> onSelected;
  43. /// 现货 tab 选中回调;若为 null 则复用 [onSelected]
  44. final ValueChanged<String>? onSpotSelected;
  45. /// 初始展示的 Tab,默认合约
  46. final SymbolPickerTab initialTab;
  47. /// 允许展示的 Tab 列表;null 或空表示全部展示
  48. final List<SymbolPickerTab>? visibleTabs;
  49. /// 弹窗标题(已废弃,Tab 本身即标题,保留为可选兼容参数)
  50. final String? title;
  51. @override
  52. ConsumerState<SymbolPickerSheet> createState() => _SymbolPickerSheetState();
  53. }
  54. class _SymbolPickerSheetState extends ConsumerState<SymbolPickerSheet> {
  55. final _searchCtrl = TextEditingController();
  56. String _query = '';
  57. late SymbolPickerTab _tab;
  58. // 本地 ticker 快照,通过节流 setState 刷新,保证所有 Riverpod
  59. // 读取发生在 build 阶段(而非 layout 阶段),避免 layout/semantics 断言崩溃。
  60. Map<String, MarketTicker> _futuresTickerBySymbol = {};
  61. Map<String, MarketTicker> _spotTickerBySymbol = {};
  62. bool _pendingRefresh = false;
  63. @override
  64. void initState() {
  65. super.initState();
  66. _tab = widget.initialTab;
  67. WidgetsBinding.instance.addPostFrameCallback((_) {
  68. _syncSnapshot();
  69. // 预加载现货数据
  70. if (_tab == SymbolPickerTab.spot) {
  71. ref.read(marketProvider.notifier).loadSpotIfNeeded();
  72. }
  73. });
  74. }
  75. void _syncSnapshot() {
  76. if (!mounted) return;
  77. final mkt = ref.read(marketProvider);
  78. setState(() {
  79. _futuresTickerBySymbol = {for (final t in mkt.tickers) t.symbol: t};
  80. _spotTickerBySymbol = {for (final t in mkt.spotTickers) t.symbol: t};
  81. });
  82. }
  83. void _scheduleRefresh() {
  84. if (_pendingRefresh) return;
  85. _pendingRefresh = true;
  86. WidgetsBinding.instance.addPostFrameCallback((_) {
  87. _pendingRefresh = false;
  88. _syncSnapshot();
  89. });
  90. }
  91. @override
  92. void dispose() {
  93. _searchCtrl.dispose();
  94. super.dispose();
  95. }
  96. @override
  97. Widget build(BuildContext context) {
  98. final cs = Theme.of(context).colorScheme;
  99. final l10n = AppLocalizations.of(context)!;
  100. // 只 watch displayOrder(币对列表结构),价格更新不触发重建
  101. final futuresSymbols = ref.watch(
  102. marketProvider.select((s) => s.displayOrder),
  103. );
  104. final spotSymbols = ref.watch(
  105. marketProvider.select((s) => s.spotDisplayOrder),
  106. );
  107. // 监听价格变化,节流刷新本地快照
  108. ref.listen<List<MarketTicker>>(
  109. marketProvider.select((s) => s.tickers),
  110. (_, __) => _scheduleRefresh(),
  111. );
  112. ref.listen<List<MarketTicker>>(
  113. marketProvider.select((s) => s.spotTickers),
  114. (_, __) => _scheduleRefresh(),
  115. );
  116. // 当前 tab 的数据
  117. final allSymbols =
  118. _tab == SymbolPickerTab.futures ? futuresSymbols : spotSymbols;
  119. final tickerBySymbol = _tab == SymbolPickerTab.futures
  120. ? _futuresTickerBySymbol
  121. : _spotTickerBySymbol;
  122. List<String> filtered;
  123. if (_query.isEmpty) {
  124. filtered = allSymbols;
  125. } else {
  126. final q = _query.toLowerCase();
  127. filtered = allSymbols.where((sym) {
  128. final t = tickerBySymbol[sym];
  129. return sym.toLowerCase().contains(q) ||
  130. (t?.baseAsset.toLowerCase().contains(q) ?? false);
  131. }).toList();
  132. }
  133. return DraggableScrollableSheet(
  134. initialChildSize: 0.85,
  135. minChildSize: 0.5,
  136. maxChildSize: 0.95,
  137. expand: false,
  138. builder: (_, scrollCtrl) => Column(
  139. children: [
  140. // ── 标题栏 ───────────────────────────────────────
  141. Padding(
  142. padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
  143. child: Row(
  144. children: [
  145. Text(
  146. l10n.selectTradingPair,
  147. style: TextStyle(
  148. color: cs.onSurface,
  149. fontSize: 16,
  150. fontWeight: FontWeight.w600,
  151. ),
  152. ),
  153. const Spacer(),
  154. GestureDetector(
  155. onTap: () => Navigator.pop(context),
  156. child: Icon(
  157. Icons.close,
  158. color: cs.onSurface.withAlpha(153),
  159. size: 20,
  160. ),
  161. ),
  162. ],
  163. ),
  164. ),
  165. // ── 合约 / 现货 Tab 切换 ────────────────────────
  166. _TabBar(
  167. current: _tab,
  168. visibleTabs: widget.visibleTabs,
  169. onChanged: (t) => setState(() {
  170. _tab = t;
  171. _query = '';
  172. _searchCtrl.clear();
  173. }),
  174. ),
  175. // ── 搜索框 ───────────────────────────────────────
  176. Padding(
  177. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  178. child: Container(
  179. height: 36,
  180. decoration: BoxDecoration(
  181. color: cs.surfaceContainerHighest.withAlpha(80),
  182. borderRadius: BorderRadius.circular(8),
  183. ),
  184. child: TextField(
  185. controller: _searchCtrl,
  186. style: TextStyle(color: cs.onSurface, fontSize: 14),
  187. onChanged: (v) => setState(() => _query = v.trim()),
  188. decoration: InputDecoration(
  189. hintText: l10n.searchHint,
  190. hintStyle: TextStyle(
  191. color: cs.onSurface.withAlpha(100), fontSize: 14),
  192. prefixIcon: Icon(
  193. Icons.search,
  194. color: cs.onSurface.withAlpha(100),
  195. size: 18,
  196. ),
  197. border: InputBorder.none,
  198. contentPadding: const EdgeInsets.symmetric(vertical: 8),
  199. ),
  200. ),
  201. ),
  202. ),
  203. // ── 列表头 ───────────────────────────────────────
  204. Padding(
  205. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  206. child: Row(
  207. children: [
  208. Expanded(
  209. child: Text(
  210. l10n.nameAndVolume,
  211. style: TextStyle(
  212. color: cs.onSurface.withAlpha(120),
  213. fontSize: 11,
  214. ),
  215. ),
  216. ),
  217. Text(
  218. l10n.latestPriceChange,
  219. style: TextStyle(
  220. color: cs.onSurface.withAlpha(120),
  221. fontSize: 11,
  222. ),
  223. ),
  224. ],
  225. ),
  226. ),
  227. const Divider(height: 1),
  228. // ── 币对列表 ─────────────────────────────────────
  229. Expanded(
  230. child: allSymbols.isEmpty
  231. ? Center(
  232. child: _tab == SymbolPickerTab.spot
  233. ? Text(
  234. l10n.noOrders,
  235. style: TextStyle(
  236. color: cs.onSurface.withAlpha(120),
  237. fontSize: 14,
  238. ),
  239. )
  240. : const CircularProgressIndicator(),
  241. )
  242. : ListView.builder(
  243. controller: scrollCtrl,
  244. itemCount: filtered.length,
  245. itemBuilder: (ctx, i) {
  246. final sym = filtered[i];
  247. final ticker = tickerBySymbol[sym];
  248. if (ticker == null) return const SizedBox.shrink();
  249. return _TickerRow(
  250. ticker: ticker,
  251. isSelected: sym == widget.currentSymbol,
  252. isSpot: _tab == SymbolPickerTab.spot,
  253. onTap: () {
  254. if (_tab == SymbolPickerTab.spot) {
  255. (widget.onSpotSelected ?? widget.onSelected)(sym);
  256. } else {
  257. widget.onSelected(sym);
  258. }
  259. },
  260. );
  261. },
  262. ),
  263. ),
  264. ],
  265. ),
  266. );
  267. }
  268. }
  269. // ── Tab 切换栏 ────────────────────────────────────────────
  270. class _TabBar extends StatelessWidget {
  271. const _TabBar({
  272. required this.current,
  273. required this.onChanged,
  274. this.visibleTabs,
  275. });
  276. final SymbolPickerTab current;
  277. final ValueChanged<SymbolPickerTab> onChanged;
  278. final List<SymbolPickerTab>? visibleTabs;
  279. @override
  280. Widget build(BuildContext context) {
  281. final cs = Theme.of(context).colorScheme;
  282. final l10n = AppLocalizations.of(context)!;
  283. Widget tab(String label, SymbolPickerTab value) {
  284. final active = current == value;
  285. return Expanded(
  286. child: GestureDetector(
  287. onTap: () => onChanged(value),
  288. behavior: HitTestBehavior.opaque,
  289. child: Container(
  290. padding: const EdgeInsets.symmetric(vertical: 10),
  291. decoration: BoxDecoration(
  292. border: Border(
  293. bottom: BorderSide(
  294. color: active ? AppColors.brand : Colors.transparent,
  295. width: 2,
  296. ),
  297. ),
  298. ),
  299. alignment: Alignment.center,
  300. child: Text(
  301. label,
  302. style: TextStyle(
  303. fontSize: 14,
  304. fontWeight: active ? FontWeight.w600 : FontWeight.w400,
  305. color: active ? cs.onSurface : cs.onSurface.withAlpha(140),
  306. ),
  307. ),
  308. ),
  309. ),
  310. );
  311. }
  312. final tabs = visibleTabs != null && visibleTabs!.isNotEmpty
  313. ? visibleTabs!
  314. : const [SymbolPickerTab.futures, SymbolPickerTab.spot];
  315. if (tabs.length <= 1) return const SizedBox.shrink();
  316. return Padding(
  317. padding: const EdgeInsets.symmetric(horizontal: 16),
  318. child: Row(
  319. children: tabs.map((t) {
  320. final label = t == SymbolPickerTab.futures
  321. ? l10n.perpetualFutures
  322. : l10n.spotTab;
  323. return tab(label, t);
  324. }).toList(),
  325. ),
  326. );
  327. }
  328. }
  329. // ── 单行 Widget(纯 StatelessWidget,无 Riverpod 订阅)──────────
  330. class _TickerRow extends StatelessWidget {
  331. const _TickerRow({
  332. required this.ticker,
  333. required this.isSelected,
  334. required this.isSpot,
  335. required this.onTap,
  336. });
  337. final MarketTicker ticker;
  338. final bool isSelected;
  339. final bool isSpot;
  340. final VoidCallback onTap;
  341. @override
  342. Widget build(BuildContext context) {
  343. final cs = Theme.of(context).colorScheme;
  344. final l10n = AppLocalizations.of(context)!;
  345. final isUp = ticker.change24h >= 0;
  346. final changeColor = AppColors.changeColor(ticker.change24h);
  347. return Material(
  348. color: Colors.transparent,
  349. child: InkWell(
  350. onTap: onTap,
  351. child: Padding(
  352. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  353. child: Row(
  354. children: [
  355. // ── 左侧:图标 + 币对名 + 成交量 ──────────────
  356. Expanded(
  357. child: Row(
  358. children: [
  359. _CoinIcon(icon: ticker.icon, baseAsset: ticker.baseAsset),
  360. const SizedBox(width: 10),
  361. Expanded(
  362. child: Column(
  363. crossAxisAlignment: CrossAxisAlignment.start,
  364. children: [
  365. Row(
  366. children: [
  367. Flexible(
  368. child: Text(
  369. formatUsdtPairDisplay(ticker.symbol),
  370. maxLines: 1,
  371. overflow: TextOverflow.ellipsis,
  372. style: TextStyle(
  373. color: isSelected
  374. ? AppColors.brand
  375. : cs.onSurface,
  376. fontSize: 13,
  377. fontWeight: FontWeight.w600,
  378. ),
  379. ),
  380. ),
  381. const SizedBox(width: 4),
  382. Container(
  383. padding: const EdgeInsets.symmetric(
  384. horizontal: 4, vertical: 1),
  385. decoration: BoxDecoration(
  386. color: cs.outline.withAlpha(40),
  387. borderRadius: BorderRadius.circular(3),
  388. ),
  389. child: Text(
  390. isSpot ? l10n.spotTab : l10n.perpetual,
  391. style: TextStyle(
  392. color: cs.onSurface.withAlpha(153),
  393. fontSize: 9,
  394. ),
  395. ),
  396. ),
  397. if (isSelected)
  398. Padding(
  399. padding: const EdgeInsets.only(left: 6),
  400. child: Icon(Icons.check,
  401. size: 14, color: AppColors.brand),
  402. ),
  403. ],
  404. ),
  405. const SizedBox(height: 2),
  406. Text(
  407. ticker.volume24h > 0
  408. ? l10n.volumeWithValue(
  409. formatVolume(ticker.volume24h))
  410. : l10n.volumeEmpty,
  411. style: TextStyle(
  412. color: cs.onSurface.withAlpha(120),
  413. fontSize: 10,
  414. ),
  415. ),
  416. ],
  417. ),
  418. ),
  419. ],
  420. ),
  421. ),
  422. // ── 右侧:价格 + 涨跌幅 ───────────────────────
  423. Column(
  424. crossAxisAlignment: CrossAxisAlignment.end,
  425. children: [
  426. Text(
  427. ticker.lastPrice > 0
  428. ? (ticker.lastPriceStr != null
  429. ? formatRawPrice(ticker.lastPriceStr!)
  430. : formatPrice(ticker.lastPrice))
  431. : '--',
  432. style: TextStyle(
  433. color: ticker.lastPrice > 0
  434. ? changeColor
  435. : cs.onSurface.withAlpha(153),
  436. fontSize: 13,
  437. fontWeight: FontWeight.w600,
  438. ),
  439. ),
  440. const SizedBox(height: 2),
  441. Text(
  442. ticker.lastPrice > 0
  443. ? '${isUp ? '+' : ''}${ticker.change24h.toStringAsFixed(2)}%'
  444. : '--',
  445. style: TextStyle(
  446. color: ticker.lastPrice > 0
  447. ? changeColor
  448. : cs.onSurface.withAlpha(100),
  449. fontSize: 10,
  450. ),
  451. ),
  452. ],
  453. ),
  454. ],
  455. ),
  456. ),
  457. ),
  458. );
  459. }
  460. }
  461. // ── 圆形图标(网络图片 / 首字母回退)──────────────────────────
  462. class _CoinIcon extends StatelessWidget {
  463. const _CoinIcon({required this.icon, required this.baseAsset});
  464. final String icon;
  465. final String baseAsset;
  466. @override
  467. Widget build(BuildContext context) {
  468. final cs = Theme.of(context).colorScheme;
  469. return Container(
  470. width: 32,
  471. height: 32,
  472. decoration: BoxDecoration(
  473. color: cs.surfaceContainerHighest.withAlpha(80),
  474. shape: BoxShape.circle,
  475. ),
  476. child: ClipOval(
  477. child: icon.isNotEmpty
  478. ? CachedNetworkImage(
  479. imageUrl: icon,
  480. width: 32,
  481. height: 32,
  482. fit: BoxFit.cover,
  483. placeholder: (_, __) => _Fallback(baseAsset: baseAsset),
  484. errorWidget: (_, __, ___) => _Fallback(baseAsset: baseAsset),
  485. )
  486. : _Fallback(baseAsset: baseAsset),
  487. ),
  488. );
  489. }
  490. }
  491. class _Fallback extends StatelessWidget {
  492. const _Fallback({required this.baseAsset});
  493. final String baseAsset;
  494. @override
  495. Widget build(BuildContext context) {
  496. return Center(
  497. child: Text(
  498. baseAsset.isNotEmpty ? baseAsset[0] : '?',
  499. style: TextStyle(
  500. color: AppColors.brand,
  501. fontWeight: FontWeight.w700,
  502. fontSize: 13,
  503. ),
  504. ),
  505. );
  506. }
  507. }