market_detail_screen.dart 86 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503
  1. import 'dart:io';
  2. import 'dart:ui' as ui;
  3. import 'package:fl_chart/fl_chart.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/rendering.dart';
  6. import 'package:flutter_riverpod/flutter_riverpod.dart';
  7. import 'package:go_router/go_router.dart';
  8. import 'package:intl/intl.dart';
  9. import 'package:k_chart_plus/chart_translations.dart';
  10. import 'package:k_chart_plus/k_chart_plus.dart';
  11. import 'package:path_provider/path_provider.dart';
  12. import 'package:share_plus/share_plus.dart';
  13. import '../../../core/l10n/app_localizations.dart';
  14. import '../../../core/theme/app_colors.dart';
  15. import '../../../core/utils/number_format.dart';
  16. import '../../../core/utils/symbol_display.dart';
  17. import '../../../core/utils/spot_order_book_convert.dart';
  18. import '../../../core/utils/top_toast.dart';
  19. import '../../../data/models/market/funding_rate.dart';
  20. import '../../../data/models/market/order_book_entry.dart';
  21. import '../../../providers/funding_rate_provider.dart';
  22. import '../../../providers/futures_provider.dart';
  23. import '../../../providers/market_detail_provider.dart';
  24. import '../../../providers/spot_provider.dart';
  25. import '../../widgets/common/app_shimmer.dart';
  26. import '../../widgets/common/coin_icon.dart';
  27. import '../../widgets/common/symbol_picker_sheet.dart';
  28. /// K 线详情页骨架(现货 / 永续分别用 [SpotMarketDetailScreen]、[FuturesMarketDetailScreen] 入口)。
  29. class MarketDetailScaffold extends ConsumerStatefulWidget {
  30. const MarketDetailScaffold({super.key, required this.marketKey});
  31. final MarketDetailKey marketKey;
  32. @override
  33. ConsumerState<MarketDetailScaffold> createState() =>
  34. _MarketDetailScaffoldState();
  35. }
  36. class _MarketDetailScaffoldState extends ConsumerState<MarketDetailScaffold> {
  37. final _shareKey = GlobalKey();
  38. bool _sharing = false;
  39. @override
  40. void initState() {
  41. super.initState();
  42. // provider 无 autoDispose,state 跨页面保留;进入页面时强制重置到"行情"tab
  43. WidgetsBinding.instance.addPostFrameCallback((_) {
  44. if (!mounted) return;
  45. ref.read(marketDetailProvider(widget.marketKey).notifier).setTopTab(0);
  46. });
  47. }
  48. Future<void> _handleShare() async {
  49. if (_sharing) return;
  50. if (!mounted) return;
  51. setState(() => _sharing = true);
  52. try {
  53. final boundary = _shareKey.currentContext?.findRenderObject()
  54. as RenderRepaintBoundary?;
  55. if (boundary == null) return;
  56. final image = await boundary.toImage(pixelRatio: 2.5);
  57. final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  58. final bytes = byteData!.buffer.asUint8List();
  59. final tempDir = await getTemporaryDirectory();
  60. final file = File(
  61. '${tempDir.path}/market_${DateTime.now().millisecondsSinceEpoch}.png');
  62. await file.writeAsBytes(bytes);
  63. // 先重置状态,再调用分享(避免用户取消时系统面板不 resolve 导致按钮卡转圈)
  64. if (mounted) setState(() => _sharing = false);
  65. Share.shareXFiles([XFile(file.path)], text: widget.marketKey.symbol);
  66. } catch (_) {
  67. if (mounted) {
  68. showTopToast(context,
  69. message: AppLocalizations.of(context)!.shareFailed,
  70. backgroundColor: AppColors.fall);
  71. }
  72. } finally {
  73. if (mounted) setState(() => _sharing = false);
  74. }
  75. }
  76. @override
  77. Widget build(BuildContext context) {
  78. final key = widget.marketKey;
  79. final isLoading = ref.watch(
  80. marketDetailProvider(key).select((s) => s.isLoading),
  81. );
  82. return Scaffold(
  83. appBar: _SymbolAppBar(
  84. marketKey: key,
  85. onShare: _handleShare,
  86. sharing: _sharing,
  87. ),
  88. body: RepaintBoundary(
  89. key: _shareKey,
  90. child: isLoading
  91. ? const _MarketDetailShimmer()
  92. : _DetailBody(marketKey: key),
  93. ),
  94. bottomNavigationBar: _BottomActions(marketKey: key),
  95. );
  96. }
  97. }
  98. // ── 顶部 AppBar(含币对切换)─────────────────────────────────
  99. class _SymbolAppBar extends ConsumerWidget implements PreferredSizeWidget {
  100. const _SymbolAppBar({
  101. required this.marketKey,
  102. required this.onShare,
  103. this.sharing = false,
  104. });
  105. final MarketDetailKey marketKey;
  106. final VoidCallback onShare;
  107. final bool sharing;
  108. String get symbol => marketKey.symbol;
  109. bool get isFutures => marketKey.isFutures;
  110. @override
  111. Size get preferredSize => const Size.fromHeight(kToolbarHeight);
  112. @override
  113. Widget build(BuildContext context, WidgetRef ref) {
  114. return AppBar(
  115. // 默认 titleSpacing 约 16,会在 leading 与 title 之间留出一块空白
  116. titleSpacing: 0,
  117. leadingWidth: 30,
  118. leading: IconButton(
  119. icon: const Icon(Icons.arrow_back_ios, size: 18),
  120. onPressed: () {
  121. if (context.canPop()) {
  122. context.pop();
  123. } else {
  124. context.go('/market');
  125. }
  126. },
  127. padding: EdgeInsets.zero,
  128. visualDensity: VisualDensity.compact,
  129. constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
  130. ),
  131. title: Semantics(
  132. label: 'market_detail_symbol_picker',
  133. button: true,
  134. onTap: () => _showSymbolPicker(context, ref),
  135. child: GestureDetector(
  136. onTap: () => _showSymbolPicker(context, ref),
  137. child: Row(
  138. mainAxisSize: MainAxisSize.min,
  139. children: [
  140. Text(formatUsdtPairDisplay(symbol),
  141. style: const TextStyle(
  142. fontSize: 17, fontWeight: FontWeight.w600)),
  143. const SizedBox(width: 4),
  144. const Icon(Icons.keyboard_arrow_down, size: 18),
  145. ],
  146. ),
  147. ),
  148. ),
  149. actions: [
  150. Semantics(
  151. label: 'market_detail_btn_share',
  152. button: true,
  153. enabled: !sharing,
  154. onTap: onShare,
  155. child: IconButton(
  156. icon: sharing
  157. ? const SizedBox(
  158. width: 18,
  159. height: 18,
  160. child: CircularProgressIndicator(strokeWidth: 2),
  161. )
  162. : const Icon(Icons.share_outlined, size: 20),
  163. onPressed: sharing ? null : onShare,
  164. ),
  165. ),
  166. ],
  167. );
  168. }
  169. void _showSymbolPicker(BuildContext context, WidgetRef ref) {
  170. showModalBottomSheet(
  171. context: context,
  172. useRootNavigator: true,
  173. isScrollControlled: true,
  174. backgroundColor: Theme.of(context).colorScheme.surface,
  175. shape: const RoundedRectangleBorder(
  176. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  177. ),
  178. builder: (sheetCtx) => SymbolPickerSheet(
  179. currentSymbol: symbol,
  180. initialTab: isFutures ? SymbolPickerTab.futures : SymbolPickerTab.spot,
  181. visibleTabs: [
  182. if (isFutures) SymbolPickerTab.futures else SymbolPickerTab.spot,
  183. ],
  184. onSelected: (newSymbol) {
  185. Navigator.pop(sheetCtx);
  186. if (newSymbol != symbol) {
  187. context.replace(isFutures
  188. ? '/market/futures/$newSymbol'
  189. : '/market/spot/$newSymbol');
  190. }
  191. },
  192. onSpotSelected: (newSymbol) {
  193. Navigator.pop(sheetCtx);
  194. if (newSymbol != symbol) {
  195. context.replace(isFutures
  196. ? '/market/futures/$newSymbol'
  197. : '/market/spot/$newSymbol');
  198. }
  199. },
  200. ),
  201. );
  202. }
  203. }
  204. // ── 详情主体 ──────────────────────────────────────────────
  205. // 页面主体:只传 symbol,各子组件自行 select 需要的数据。
  206. // 按 topTab 切换行情/概览内容,未激活的 tab 不构建。
  207. class _DetailBody extends ConsumerWidget {
  208. const _DetailBody({required this.marketKey});
  209. final MarketDetailKey marketKey;
  210. @override
  211. Widget build(BuildContext context, WidgetRef ref) {
  212. final symbol = marketKey.symbol;
  213. final topTab = ref.watch(
  214. marketDetailProvider(marketKey).select((s) => s.topTab),
  215. );
  216. Widget content;
  217. if (topTab == 0) {
  218. content = _MarketContent(marketKey: marketKey);
  219. } else if (topTab == 1) {
  220. content = _InfoTab(marketKey: marketKey);
  221. } else {
  222. content = _DataTab(symbol: symbol);
  223. }
  224. return Column(
  225. children: [
  226. _TopTabs(marketKey: marketKey),
  227. Expanded(child: content),
  228. ],
  229. );
  230. }
  231. }
  232. // 整个页面可上下滚动。K 线图用固定高度 450。
  233. // KChartWidget 内部用 onHorizontalDrag 处理水平拖拽,
  234. // Flutter 手势竞技场自动区分水平/垂直 — 垂直滑动归 SingleChildScrollView。
  235. // 不需要动态切换 physics。
  236. class _MarketContent extends StatefulWidget {
  237. const _MarketContent({required this.marketKey});
  238. final MarketDetailKey marketKey;
  239. @override
  240. State<_MarketContent> createState() => _MarketContentState();
  241. }
  242. class _MarketContentState extends State<_MarketContent> {
  243. final _dismissNotifier = ValueNotifier<bool>(false);
  244. // 用于判断点击位置是否在 K 线图区域内
  245. final _chartKey = GlobalKey();
  246. @override
  247. void dispose() {
  248. _dismissNotifier.dispose();
  249. super.dispose();
  250. }
  251. /// 点击 K 线图区域外部才触发 dismiss;
  252. /// 图表内部的 tap 由 KChartWidget 自身处理(展示/切换蜡烛详情)。
  253. void _onTapDown(TapDownDetails details) {
  254. final ro = _chartKey.currentContext?.findRenderObject() as RenderBox?;
  255. if (ro != null) {
  256. final local = ro.globalToLocal(details.globalPosition);
  257. if (local.dx >= 0 &&
  258. local.dx <= ro.size.width &&
  259. local.dy >= 0 &&
  260. local.dy <= ro.size.height) {
  261. return; // 点击在图表内部,交给 KChartWidget 处理
  262. }
  263. }
  264. _dismissNotifier.value = !_dismissNotifier.value;
  265. }
  266. @override
  267. Widget build(BuildContext context) {
  268. return GestureDetector(
  269. behavior: HitTestBehavior.translucent,
  270. onTapDown: _onTapDown,
  271. child: SingleChildScrollView(
  272. physics: const ClampingScrollPhysics(),
  273. child: Column(
  274. children: [
  275. _PriceHeader(marketKey: widget.marketKey),
  276. _PeriodTabBar(marketKey: widget.marketKey),
  277. SizedBox(
  278. key: _chartKey,
  279. child: _KlineChartArea(
  280. marketKey: widget.marketKey,
  281. dismissNotifier: _dismissNotifier)),
  282. _OrderBookSection(marketKey: widget.marketKey),
  283. const SizedBox(height: 16),
  284. ],
  285. ),
  286. ),
  287. );
  288. }
  289. }
  290. // ── 顶部 Tab(价格/信息)────────────────────────────────────
  291. // 只 select topTab,行情/概览切换不触发其他区域重建
  292. class _TopTabs extends ConsumerWidget {
  293. const _TopTabs({required this.marketKey});
  294. final MarketDetailKey marketKey;
  295. List<String> _getLabels(BuildContext context) {
  296. final l10n = AppLocalizations.of(context)!;
  297. final tabs = [l10n.market, l10n.marketOverview];
  298. if (marketKey.isFutures) tabs.add(l10n.marketData);
  299. return tabs;
  300. }
  301. @override
  302. Widget build(BuildContext context, WidgetRef ref) {
  303. final provider = marketDetailProvider(marketKey);
  304. final topTab = ref.watch(provider.select((s) => s.topTab));
  305. final notifier = ref.read(provider.notifier);
  306. final cs = Theme.of(context).colorScheme;
  307. final semanticsLabels = [
  308. 'market_detail_tab_chart',
  309. 'market_detail_tab_overview',
  310. if (marketKey.isFutures) 'market_detail_tab_data',
  311. ];
  312. return Container(
  313. decoration: BoxDecoration(
  314. border: Border(bottom: BorderSide(color: cs.outline)),
  315. ),
  316. child: Row(
  317. children: List.generate(_getLabels(context).length, (i) {
  318. final selected = i == topTab;
  319. return Semantics(
  320. label: semanticsLabels[i],
  321. button: true,
  322. enabled: true,
  323. onTap: () => notifier.setTopTab(i),
  324. child: GestureDetector(
  325. behavior: HitTestBehavior.opaque,
  326. onTap: () => notifier.setTopTab(i),
  327. child: Padding(
  328. padding: const EdgeInsets.only(left: 16, right: 8),
  329. child: Column(
  330. children: [
  331. Padding(
  332. padding: const EdgeInsets.symmetric(vertical: 6),
  333. child: Text(
  334. _getLabels(context)[i],
  335. style: TextStyle(
  336. fontSize: 14,
  337. color: selected
  338. ? cs.onSurface
  339. : cs.onSurface.withAlpha(153),
  340. fontWeight:
  341. selected ? FontWeight.w600 : FontWeight.w400,
  342. ),
  343. ),
  344. ),
  345. // 未选中时用透明占位保持高度一致,避免切换时布局跳动
  346. Container(
  347. height: 2,
  348. width: 24,
  349. color: selected ? AppColors.brand : Colors.transparent,
  350. ),
  351. ],
  352. ),
  353. ),
  354. ),
  355. );
  356. }),
  357. ),
  358. );
  359. }
  360. }
  361. // ── 价格区 ────────────────────────────────────────────────
  362. // 只 select stats,K线更新不会触发价格区重建
  363. class _PriceHeader extends ConsumerWidget {
  364. const _PriceHeader({required this.marketKey});
  365. final MarketDetailKey marketKey;
  366. @override
  367. Widget build(BuildContext context, WidgetRef ref) {
  368. final symbol = marketKey.symbol;
  369. final isFutures = marketKey.isFutures;
  370. final cs = Theme.of(context).colorScheme;
  371. final stats = ref.watch(
  372. marketDetailProvider(marketKey).select((s) => s.stats),
  373. );
  374. if (stats == null) return const SizedBox.shrink();
  375. final priceColor = AppColors.changeColor(stats.change24h);
  376. final sign = stats.change24h >= 0 ? '+' : '';
  377. // 提取基础币名(BTCUSDT → BTC)
  378. final baseCoin = symbol.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
  379. return Padding(
  380. padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
  381. child: Column(
  382. crossAxisAlignment: CrossAxisAlignment.start,
  383. children: [
  384. // 大价格:用主文字色,禁止跟随涨跌色(规范 1.2 节)
  385. Text(
  386. stats.lastPriceStr != null
  387. ? formatRawPrice(stats.lastPriceStr!)
  388. : formatPrice(stats.lastPrice),
  389. style: TextStyle(
  390. color: cs.onSurface,
  391. fontSize: 26,
  392. fontWeight: FontWeight.w700,
  393. letterSpacing: -0.5,
  394. fontFeatures: const [FontFeature.tabularFigures()],
  395. ),
  396. ),
  397. const SizedBox(height: 2),
  398. // 副行:≈$xxx | 涨跌幅 |(合约:标记价格)
  399. Row(
  400. children: [
  401. Flexible(
  402. child: Text(
  403. '≈ \$${formatPrice(stats.lastPrice)}',
  404. maxLines: 1,
  405. overflow: TextOverflow.ellipsis,
  406. style: TextStyle(
  407. color: cs.onSurface.withAlpha(153), fontSize: 11),
  408. ),
  409. ),
  410. const SizedBox(width: 8),
  411. Text(
  412. '$sign${stats.change24h.toStringAsFixed(2)}%',
  413. style: TextStyle(
  414. color: priceColor,
  415. fontSize: 11,
  416. fontWeight: FontWeight.w500),
  417. ),
  418. if (isFutures) ...[
  419. const SizedBox(width: 8),
  420. Flexible(
  421. child: Text(
  422. '${AppLocalizations.of(context)!.markPrice} ${formatPrice(stats.markPrice)}',
  423. maxLines: 1,
  424. overflow: TextOverflow.ellipsis,
  425. style: TextStyle(
  426. color: cs.onSurface.withAlpha(153), fontSize: 11),
  427. ),
  428. ),
  429. ],
  430. ],
  431. ),
  432. const SizedBox(height: 8),
  433. // 合约:3 列(含资金费率/倒计时);现货:2 列
  434. Row(
  435. children: [
  436. // 列1:最高/最低
  437. Expanded(
  438. child: Column(
  439. crossAxisAlignment: CrossAxisAlignment.start,
  440. children: [
  441. _StatItem(
  442. label: AppLocalizations.of(context)!.high24h,
  443. value: formatPrice(stats.high24h),
  444. color: AppColors.rise),
  445. const SizedBox(height: 6),
  446. _StatItem(
  447. label: AppLocalizations.of(context)!.low24h,
  448. value: formatPrice(stats.low24h),
  449. color: AppColors.fall),
  450. ],
  451. ),
  452. ),
  453. // 列2:成交量/额
  454. Expanded(
  455. child: Column(
  456. crossAxisAlignment: CrossAxisAlignment.start,
  457. children: [
  458. _StatItem(
  459. label: AppLocalizations.of(context)!
  460. .volume24hLabel(baseCoin),
  461. value: stats.volume24h >= 1000
  462. ? '${(stats.volume24h / 1000).toStringAsFixed(2)}K'
  463. : stats.volume24h.toStringAsFixed(2),
  464. ),
  465. const SizedBox(height: 6),
  466. _StatItem(
  467. label: AppLocalizations.of(context)!.turnover24hLabel,
  468. value: stats.turnover24h >= 1e6
  469. ? '${(stats.turnover24h / 1e6).toStringAsFixed(2)}M'
  470. : '${(stats.turnover24h / 1000).toStringAsFixed(2)}K',
  471. ),
  472. ],
  473. ),
  474. ),
  475. if (isFutures)
  476. Expanded(
  477. child: _FundingColumn(symbol: symbol),
  478. ),
  479. ],
  480. ),
  481. ],
  482. ),
  483. );
  484. }
  485. }
  486. // 资金费率 + 倒计时列(仅永续合约行情页展示)
  487. class _FundingColumn extends ConsumerWidget {
  488. const _FundingColumn({required this.symbol});
  489. final String symbol;
  490. @override
  491. Widget build(BuildContext context, WidgetRef ref) {
  492. final fundingRate = ref.watch(
  493. futuresProvider(symbol).select((s) => s.fundingRate),
  494. );
  495. final fundingCountdown = ref.watch(
  496. futuresProvider(symbol).select((s) => s.fundingCountdown),
  497. );
  498. final sign = fundingRate >= 0 ? '+' : '';
  499. final rate = '$sign${(fundingRate * 100).toStringAsFixed(4)}%';
  500. final countdown = fundingCountdown;
  501. return Column(
  502. crossAxisAlignment: CrossAxisAlignment.end,
  503. children: [
  504. _StatItem(
  505. label: AppLocalizations.of(context)!.fundingRate,
  506. value: rate,
  507. align: TextAlign.right),
  508. const SizedBox(height: 6),
  509. _StatItem(
  510. label: AppLocalizations.of(context)!.countdown,
  511. value: countdown,
  512. align: TextAlign.right),
  513. ],
  514. );
  515. }
  516. }
  517. class _StatItem extends StatelessWidget {
  518. const _StatItem(
  519. {required this.label, required this.value, this.color, this.align});
  520. final String label;
  521. final String value;
  522. final Color? color;
  523. final TextAlign? align;
  524. @override
  525. Widget build(BuildContext context) {
  526. final cs = Theme.of(context).colorScheme;
  527. return Column(
  528. crossAxisAlignment: align == TextAlign.right
  529. ? CrossAxisAlignment.end
  530. : CrossAxisAlignment.start,
  531. children: [
  532. Text(label,
  533. textAlign: align,
  534. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
  535. const SizedBox(height: 1),
  536. Text(
  537. value,
  538. textAlign: align,
  539. style: TextStyle(
  540. color: color ?? cs.onSurface,
  541. fontSize: 11,
  542. fontWeight: FontWeight.w600,
  543. ),
  544. ),
  545. ],
  546. );
  547. }
  548. }
  549. String _klinePeriodLabel(KlinePeriod p, AppLocalizations l10n) {
  550. switch (p) {
  551. case KlinePeriod.min1:
  552. return l10n.klinePeriod1m;
  553. case KlinePeriod.min5:
  554. return l10n.klinePeriod5m;
  555. case KlinePeriod.min15:
  556. return l10n.klinePeriod15m;
  557. case KlinePeriod.min30:
  558. return l10n.klinePeriod30m;
  559. case KlinePeriod.hour1:
  560. return l10n.klinePeriod1h;
  561. case KlinePeriod.hour4:
  562. return l10n.klinePeriod4h;
  563. case KlinePeriod.day1:
  564. return l10n.klinePeriod1d;
  565. case KlinePeriod.week1:
  566. return l10n.klinePeriod1w;
  567. case KlinePeriod.month1:
  568. return l10n.klinePeriod1mon;
  569. }
  570. }
  571. // ── 周期 Tab ──────────────────────────────────────────────
  572. // 只 select period,其他字段变化不触发重建
  573. class _PeriodTabBar extends ConsumerWidget {
  574. const _PeriodTabBar({required this.marketKey});
  575. final MarketDetailKey marketKey;
  576. @override
  577. Widget build(BuildContext context, WidgetRef ref) {
  578. final cs = Theme.of(context).colorScheme;
  579. final l10n = AppLocalizations.of(context)!;
  580. final period = ref.watch(
  581. marketDetailProvider(marketKey).select((s) => s.period),
  582. );
  583. final onChanged =
  584. ref.read(marketDetailProvider(marketKey).notifier).setPeriod;
  585. // 主栏只展示前4个周期,其余通过"更多"选择
  586. const mainPeriods = [
  587. KlinePeriod.min1,
  588. KlinePeriod.min5,
  589. KlinePeriod.min15,
  590. KlinePeriod.hour1,
  591. KlinePeriod.day1
  592. ];
  593. final inMore = !mainPeriods.contains(period);
  594. return Container(
  595. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
  596. decoration: BoxDecoration(
  597. border: Border(bottom: BorderSide(color: cs.outline)),
  598. ),
  599. child: Row(
  600. children: [
  601. ...mainPeriods.map((p) {
  602. final selected = p == period;
  603. return Semantics(
  604. label: 'market_detail_period_${p.wsInterval}',
  605. button: true,
  606. enabled: true,
  607. onTap: () => onChanged(p),
  608. child: GestureDetector(
  609. onTap: () => onChanged(p),
  610. child: Padding(
  611. padding:
  612. const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
  613. child: Text(
  614. _klinePeriodLabel(p, l10n),
  615. style: TextStyle(
  616. fontSize: 13,
  617. color: selected
  618. ? AppColors.brand
  619. : cs.onSurface.withAlpha(153),
  620. fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
  621. ),
  622. ),
  623. ),
  624. ),
  625. );
  626. }),
  627. const Spacer(),
  628. GestureDetector(
  629. onTap: () => _showMorePeriods(context, period, onChanged, l10n),
  630. child: Padding(
  631. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
  632. child: Row(
  633. mainAxisSize: MainAxisSize.min,
  634. children: [
  635. Text(
  636. inMore ? _klinePeriodLabel(period, l10n) : l10n.more,
  637. style: TextStyle(
  638. fontSize: 13,
  639. color: inMore
  640. ? AppColors.brand
  641. : cs.onSurface.withAlpha(153),
  642. fontWeight: inMore ? FontWeight.w600 : FontWeight.w400,
  643. ),
  644. ),
  645. Icon(Icons.keyboard_arrow_down,
  646. size: 14,
  647. color: inMore
  648. ? AppColors.brand
  649. : cs.onSurface.withAlpha(153)),
  650. ],
  651. ),
  652. ),
  653. ),
  654. ],
  655. ),
  656. );
  657. }
  658. }
  659. void _showMorePeriods(
  660. BuildContext context,
  661. KlinePeriod current,
  662. void Function(KlinePeriod) onChanged,
  663. AppLocalizations l10n,
  664. ) {
  665. final cs = Theme.of(context).colorScheme;
  666. showModalBottomSheet<void>(
  667. context: context,
  668. useRootNavigator: true,
  669. isScrollControlled: true,
  670. constraints: const BoxConstraints(maxWidth: double.infinity),
  671. backgroundColor: Theme.of(context).colorScheme.surface,
  672. shape: const RoundedRectangleBorder(
  673. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  674. ),
  675. builder: (_) {
  676. return SafeArea(
  677. child: Column(
  678. mainAxisSize: MainAxisSize.min,
  679. crossAxisAlignment: CrossAxisAlignment.stretch,
  680. children: [
  681. const SizedBox(height: 12),
  682. Center(
  683. child: Container(
  684. width: 36,
  685. height: 4,
  686. decoration: BoxDecoration(
  687. color: cs.outline,
  688. borderRadius: BorderRadius.circular(2),
  689. )),
  690. ),
  691. const SizedBox(height: 16),
  692. Padding(
  693. padding: const EdgeInsets.symmetric(horizontal: 16),
  694. child: Wrap(
  695. spacing: 12,
  696. runSpacing: 12,
  697. children: KlinePeriod.values.map((p) {
  698. final selected = p == current;
  699. return GestureDetector(
  700. onTap: () {
  701. onChanged(p);
  702. Navigator.of(context).pop();
  703. },
  704. child: Container(
  705. padding: const EdgeInsets.symmetric(
  706. horizontal: 20, vertical: 10),
  707. decoration: BoxDecoration(
  708. color: selected
  709. ? AppColors.brand.withAlpha(30)
  710. : cs.surface,
  711. border: Border.all(
  712. color: selected ? AppColors.brand : cs.outline,
  713. ),
  714. borderRadius: BorderRadius.circular(6),
  715. ),
  716. child: Text(
  717. _klinePeriodLabel(p, l10n),
  718. style: TextStyle(
  719. fontSize: 14,
  720. color: selected ? AppColors.brand : cs.onSurface,
  721. fontWeight:
  722. selected ? FontWeight.w600 : FontWeight.w400,
  723. ),
  724. ),
  725. ),
  726. );
  727. }).toList(),
  728. ),
  729. ),
  730. const SizedBox(height: 24),
  731. ],
  732. ),
  733. );
  734. },
  735. );
  736. }
  737. // ── K 线图区域(使用 k_chart_plus)──────────────────────
  738. class _KlineChartArea extends ConsumerStatefulWidget {
  739. const _KlineChartArea({required this.marketKey, this.dismissNotifier});
  740. final MarketDetailKey marketKey;
  741. final ValueNotifier<bool>? dismissNotifier;
  742. @override
  743. ConsumerState<_KlineChartArea> createState() => _KlineChartAreaState();
  744. }
  745. class _KlineChartAreaState extends ConsumerState<_KlineChartArea> {
  746. // 主图指标(MA/BOLL/SAR 可多选)—— 默认只选 MA
  747. Set<MainState> _mainStates = {MainState.MA};
  748. // 副图指标(MACD/KDJ/RSI/WR/CCI 可多选)—— 默认全不选
  749. Set<SecondaryState> _secondaryStates = {};
  750. bool _volHidden = false;
  751. /// 去掉 WS 原始价格字符串的尾零,用于图表右侧标签(不加千分符)
  752. static String _stripRawPrice(String s) {
  753. final parts = s.split('.');
  754. if (parts.length < 2) return s;
  755. final decimal = parts[1].replaceAll(RegExp(r'0+$'), '');
  756. return decimal.isEmpty ? parts[0] : '${parts[0]}.$decimal';
  757. }
  758. @override
  759. Widget build(BuildContext context) {
  760. final cs = Theme.of(context).colorScheme;
  761. final isDark = Theme.of(context).brightness == Brightness.dark;
  762. final provider = marketDetailProvider(widget.marketKey);
  763. final entities = ref.watch(provider.select((s) => s.entities));
  764. final nowPriceStr = ref.watch(provider.select((s) => s.stats?.lastPriceStr));
  765. final notifier = ref.read(provider.notifier);
  766. if (entities.isEmpty) {
  767. final isLoading = ref.watch(provider.select((s) => s.isLoading));
  768. return SizedBox(
  769. height: 350,
  770. child: Center(
  771. child: isLoading
  772. ? const CircularProgressIndicator(strokeWidth: 2)
  773. : Text(AppLocalizations.of(context)!.noKlineData,
  774. style: TextStyle(
  775. color: cs.onSurface.withAlpha(128), fontSize: 14)),
  776. ),
  777. );
  778. }
  779. // 动态计算图表高度:主图 + VOL(20%) + 副图(20%×数量) + 标签(12×数量)
  780. const baseH = 260.0;
  781. final volH = _volHidden ? 0.0 : baseH * 0.2;
  782. final secH = baseH * 0.2 * _secondaryStates.length;
  783. final labelH = 12.0 * _mainStates.length;
  784. final chartH = baseH + volH + secH + labelH;
  785. return Column(
  786. mainAxisSize: MainAxisSize.min,
  787. children: [
  788. // K 线图(高度随指标数量动态变化)
  789. SizedBox(
  790. height: chartH,
  791. child: Stack(
  792. children: [
  793. KChartWidget(
  794. entities,
  795. _chartStyle,
  796. _buildChartColors(cs, isDark),
  797. isTrendLine: false,
  798. isTapShowInfoDialog: true,
  799. dismissInfoNotifier: widget.dismissNotifier,
  800. mainStateLi: _mainStates,
  801. secondaryStateLi: _secondaryStates,
  802. volHidden: _volHidden,
  803. showNowPrice: true,
  804. maDayList: const [5, 10, 20],
  805. mBaseHeight: baseH,
  806. fixedLength: 2,
  807. nowPriceStr: nowPriceStr != null
  808. ? _stripRawPrice(nowPriceStr)
  809. : null,
  810. timeFormat: const [
  811. yyyy,
  812. '-',
  813. mm,
  814. '-',
  815. dd,
  816. ' ',
  817. HH,
  818. ':',
  819. nn,
  820. ':',
  821. ss
  822. ],
  823. chartTranslations: _buildChartTranslations(context),
  824. verticalTextAlignment: VerticalTextAlignment.right,
  825. onLoadMore: (isLeft) {
  826. if (!isLeft) notifier.loadMoreKlines();
  827. },
  828. isOnDrag: (isDrag) {},
  829. ),
  830. // 水印:logo(去黑底)+ 文字,居中半透明叠加
  831. const _KlineWatermark(),
  832. ],
  833. ),
  834. ),
  835. // 指标选择器(主图 + 副图)
  836. _buildIndicatorBar(),
  837. ],
  838. );
  839. }
  840. /// 指标切换栏:MA BOLL SAR | VOL MACD KDJ RSI WR
  841. Widget _buildIndicatorBar() {
  842. final cs = Theme.of(context).colorScheme;
  843. return Container(
  844. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  845. decoration: BoxDecoration(
  846. border: Border(top: BorderSide(color: cs.outline)),
  847. ),
  848. child: SingleChildScrollView(
  849. scrollDirection: Axis.horizontal,
  850. child: Row(
  851. children: [
  852. // 主图指标
  853. ..._mainIndicators.map((e) => _indicatorChip(
  854. label: e.$1,
  855. active: _mainStates.contains(e.$2),
  856. onTap: () => setState(() {
  857. if (_mainStates.contains(e.$2)) {
  858. _mainStates = Set.from(_mainStates)..remove(e.$2);
  859. } else {
  860. _mainStates = Set.from(_mainStates)..add(e.$2);
  861. }
  862. }),
  863. )),
  864. // 分隔线
  865. Container(
  866. width: 1,
  867. height: 16,
  868. color: cs.outline,
  869. margin: const EdgeInsets.symmetric(horizontal: 6)),
  870. // VOL 开关
  871. _indicatorChip(
  872. label: 'VOL',
  873. active: !_volHidden,
  874. onTap: () => setState(() => _volHidden = !_volHidden),
  875. ),
  876. // 副图指标
  877. ..._secondaryIndicators.map((e) => _indicatorChip(
  878. label: e.$1,
  879. active: _secondaryStates.contains(e.$2),
  880. onTap: () => setState(() {
  881. if (_secondaryStates.contains(e.$2)) {
  882. _secondaryStates = Set.from(_secondaryStates)
  883. ..remove(e.$2);
  884. } else {
  885. _secondaryStates = Set.from(_secondaryStates)..add(e.$2);
  886. }
  887. }),
  888. )),
  889. ],
  890. ),
  891. ),
  892. );
  893. }
  894. Widget _indicatorChip({
  895. required String label,
  896. required bool active,
  897. required VoidCallback onTap,
  898. }) {
  899. final cs = Theme.of(context).colorScheme;
  900. return GestureDetector(
  901. behavior: HitTestBehavior.opaque,
  902. onTap: onTap,
  903. child: Container(
  904. margin: const EdgeInsets.symmetric(horizontal: 4),
  905. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
  906. decoration: BoxDecoration(
  907. color: active ? AppColors.brand.withAlpha(30) : Colors.transparent,
  908. borderRadius: BorderRadius.circular(4),
  909. ),
  910. child: Text(
  911. label,
  912. style: TextStyle(
  913. fontSize: 12,
  914. color: active ? AppColors.brand : cs.onSurface.withAlpha(153),
  915. fontWeight: active ? FontWeight.w600 : FontWeight.w400,
  916. ),
  917. ),
  918. ),
  919. );
  920. }
  921. static const _mainIndicators = [
  922. ('MA', MainState.MA),
  923. ('BOLL', MainState.BOLL),
  924. ('SAR', MainState.SAR),
  925. ];
  926. static const _secondaryIndicators = [
  927. ('MACD', SecondaryState.MACD),
  928. ('KDJ', SecondaryState.KDJ),
  929. ('RSI', SecondaryState.RSI),
  930. ('WR', SecondaryState.WR),
  931. ('CCI', SecondaryState.CCI),
  932. ];
  933. // ── 主题感知图表配色 ────────────────────────────────
  934. ChartColors _buildChartColors(ColorScheme cs, bool isDark) => ChartColors(
  935. bgColor: isDark ? AppColors.darkBg : AppColors.lightBg,
  936. ma5Color: AppColors.chartMa5,
  937. ma10Color: AppColors.chartMa10,
  938. ma30Color: AppColors.chartMa30,
  939. upColor: AppColors.rise,
  940. dnColor: AppColors.fall,
  941. volColor: AppColors.chartLineBlue,
  942. macdColor: AppColors.chartLineBlue,
  943. difColor: AppColors.chartMa10,
  944. deaColor: AppColors.chartMa30,
  945. nowPriceUpColor: AppColors.rise,
  946. nowPriceDnColor: AppColors.fall,
  947. nowPriceTextColor: Colors.white,
  948. gridColor: cs.outline,
  949. kLineColor: AppColors.chartLineBlue,
  950. selectBorderColor: cs.outline,
  951. selectFillColor:
  952. isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  953. defaultTextColor:
  954. isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
  955. maxColor: cs.onSurface,
  956. minColor: cs.onSurface,
  957. infoWindowNormalColor: cs.onSurface,
  958. infoWindowTitleColor:
  959. isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
  960. infoWindowUpColor: AppColors.rise,
  961. infoWindowDnColor: AppColors.fall,
  962. hCrossColor: cs.onSurface,
  963. vCrossColor: cs.onSurface,
  964. crossTextColor: cs.onSurface,
  965. );
  966. static final _chartStyle = ChartStyle()
  967. ..childPadding = 12.0 // 主图与 VOL/副图之间的间距
  968. ..bottomPadding = 20.0 // 底部留给时间轴标签
  969. ..vCrossWidth = 0.5 // 十字线竖线宽度(默认 8.5 过粗)
  970. ..hCrossWidth = 0.5; // 十字线横线宽度
  971. ChartTranslations _buildChartTranslations(BuildContext context) {
  972. final l10n = AppLocalizations.of(context)!;
  973. return ChartTranslations(
  974. date: l10n.klineDate,
  975. open: l10n.klineOpen,
  976. high: l10n.klineHigh,
  977. low: l10n.klineLow,
  978. close: l10n.klineClose,
  979. changeAmount: l10n.klineChangeAmt,
  980. change: l10n.klineChange,
  981. amount: l10n.klineAmount,
  982. vol: l10n.klineVol,
  983. );
  984. }
  985. }
  986. // ── 订单簿区域 ────────────────────────────────────────────
  987. // 只 select orderBook 和 bottomTab,K线/价格变化不触发订单簿重建
  988. class _OrderBookSection extends ConsumerWidget {
  989. const _OrderBookSection({required this.marketKey});
  990. final MarketDetailKey marketKey;
  991. List<String> _getTabs(BuildContext context) {
  992. final l10n = AppLocalizations.of(context)!;
  993. return [l10n.orderBook, l10n.latestTrades, l10n.depthChart];
  994. }
  995. @override
  996. Widget build(BuildContext context, WidgetRef ref) {
  997. final symbol = marketKey.symbol;
  998. final isFutures = marketKey.isFutures;
  999. final cs = Theme.of(context).colorScheme;
  1000. final provider = marketDetailProvider(marketKey);
  1001. final OrderBook? orderBook;
  1002. int? obPriceDecimals;
  1003. int obQtyDecimals;
  1004. if (isFutures) {
  1005. orderBook = ref.watch(provider.select((s) => s.orderBook));
  1006. obPriceDecimals = null;
  1007. obQtyDecimals = 4;
  1008. } else {
  1009. final spot = ref.watch(spotProvider(symbol));
  1010. orderBook = spotRawDepthToOrderBook(
  1011. rawAsks: spot.orderBookAsks,
  1012. rawBids: spot.orderBookBids,
  1013. depthPrecision: spot.depth2Pre,
  1014. );
  1015. obPriceDecimals = spot.depth2Pre;
  1016. obQtyDecimals = spot.volumePrecision;
  1017. }
  1018. final tabIndex = ref.watch(provider.select((s) => s.bottomTab));
  1019. final onTabChanged = ref.read(provider.notifier).setBottomTab;
  1020. final tabs = _getTabs(context);
  1021. return Column(
  1022. children: [
  1023. // Tab 行
  1024. Container(
  1025. decoration: BoxDecoration(
  1026. border: Border(bottom: BorderSide(color: cs.outline)),
  1027. ),
  1028. child: Row(
  1029. children: List.generate(tabs.length, (i) {
  1030. final selected = i == tabIndex;
  1031. return GestureDetector(
  1032. behavior: HitTestBehavior.opaque, // 整个 padding 区域都响应点击
  1033. onTap: () => onTabChanged(i),
  1034. child: Padding(
  1035. padding: const EdgeInsets.symmetric(horizontal: 16),
  1036. child: Column(
  1037. children: [
  1038. Padding(
  1039. padding: const EdgeInsets.symmetric(vertical: 14),
  1040. child: Text(
  1041. tabs[i],
  1042. style: TextStyle(
  1043. fontSize: 14,
  1044. color: selected
  1045. ? cs.onSurface
  1046. : cs.onSurface.withAlpha(153),
  1047. fontWeight:
  1048. selected ? FontWeight.w600 : FontWeight.w400,
  1049. ),
  1050. ),
  1051. ),
  1052. Container(
  1053. height: 2,
  1054. width: 24,
  1055. color: selected ? AppColors.brand : Colors.transparent,
  1056. ),
  1057. ],
  1058. ),
  1059. ),
  1060. );
  1061. }),
  1062. ),
  1063. ),
  1064. // 按 tab 切换内容
  1065. if (tabIndex == 0) ...[
  1066. // ── 订单簿 ────────────────────────────────
  1067. _MergedOrderBook(
  1068. orderBook: orderBook,
  1069. priceDecimalPlaces: obPriceDecimals,
  1070. qtyDecimals: obQtyDecimals,
  1071. ),
  1072. ] else if (tabIndex == 1) ...[
  1073. // ── 最新成交 ──────────────────────────────
  1074. _RecentTradesPanel(marketKey: marketKey),
  1075. ] else if (tabIndex == 2) ...[
  1076. // ── 深度图 ────────────────────────────────
  1077. _DepthChartPanel(marketKey: marketKey),
  1078. ],
  1079. ],
  1080. );
  1081. }
  1082. }
  1083. // ── 合并订单簿(每行同时显示买盘/卖盘配对)─────────────────────
  1084. // 原型设计:买入qty | 买价 卖价 | 卖出qty,每行对应同一档位索引
  1085. class _MergedOrderBook extends StatelessWidget {
  1086. const _MergedOrderBook({
  1087. required this.orderBook,
  1088. this.priceDecimalPlaces,
  1089. this.qtyDecimals = 4,
  1090. });
  1091. final OrderBook? orderBook;
  1092. /// 现货:与交易对 `depth2Pre` 一致;合约为 null 走 formatPrice 默认规则
  1093. final int? priceDecimalPlaces;
  1094. final int qtyDecimals;
  1095. @override
  1096. Widget build(BuildContext context) {
  1097. final cs = Theme.of(context).colorScheme;
  1098. if (orderBook == null) {
  1099. return const SizedBox(
  1100. height: 240,
  1101. child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
  1102. );
  1103. }
  1104. final ob = orderBook!;
  1105. // bids 按价格降序(index 0 = 最优买价),asks 按价格升序(index 0 = 最优卖价)
  1106. final bids = ob.bids.take(12).toList();
  1107. final asks = ob.asks.take(12).toList();
  1108. final rowCount = bids.length > asks.length ? bids.length : asks.length;
  1109. final maxBidAmt = bids.isEmpty
  1110. ? 1.0
  1111. : bids.map((e) => e.amount).fold(0.0, (a, b) => a > b ? a : b);
  1112. final maxAskAmt = asks.isEmpty
  1113. ? 1.0
  1114. : asks.map((e) => e.amount).fold(0.0, (a, b) => a > b ? a : b);
  1115. return Column(
  1116. children: [
  1117. // 表头
  1118. Padding(
  1119. padding: const EdgeInsets.fromLTRB(12, 6, 12, 2),
  1120. child: Row(
  1121. children: [
  1122. Expanded(
  1123. child: Text(AppLocalizations.of(context)!.buy,
  1124. style: TextStyle(color: AppColors.rise, fontSize: 10)),
  1125. ),
  1126. Expanded(
  1127. child: Text(
  1128. AppLocalizations.of(context)!.priceLabel,
  1129. textAlign: TextAlign.center,
  1130. style: TextStyle(
  1131. color: cs.onSurface.withAlpha(153), fontSize: 10),
  1132. ),
  1133. ),
  1134. Expanded(
  1135. child: Text(
  1136. AppLocalizations.of(context)!.sell,
  1137. textAlign: TextAlign.right,
  1138. style: TextStyle(color: AppColors.fall, fontSize: 10),
  1139. ),
  1140. ),
  1141. ],
  1142. ),
  1143. ),
  1144. // 数据行:买卖配对展示
  1145. for (int i = 0; i < rowCount; i++)
  1146. _PairedRow(
  1147. bid: i < bids.length ? bids[i] : null,
  1148. ask: i < asks.length ? asks[i] : null,
  1149. bidDepth: i < bids.length
  1150. ? (bids[i].amount / maxBidAmt).clamp(0.0, 1.0)
  1151. : 0.0,
  1152. askDepth: i < asks.length
  1153. ? (asks[i].amount / maxAskAmt).clamp(0.0, 1.0)
  1154. : 0.0,
  1155. priceDecimalPlaces: priceDecimalPlaces,
  1156. qtyDecimals: qtyDecimals,
  1157. ),
  1158. ],
  1159. );
  1160. }
  1161. }
  1162. class _PairedRow extends StatelessWidget {
  1163. const _PairedRow({
  1164. this.bid,
  1165. this.ask,
  1166. required this.bidDepth,
  1167. required this.askDepth,
  1168. this.priceDecimalPlaces,
  1169. this.qtyDecimals = 4,
  1170. });
  1171. final OrderBookEntry? bid;
  1172. final OrderBookEntry? ask;
  1173. final double bidDepth;
  1174. final double askDepth;
  1175. final int? priceDecimalPlaces;
  1176. final int qtyDecimals;
  1177. @override
  1178. Widget build(BuildContext context) {
  1179. final cs = Theme.of(context).colorScheme;
  1180. return SizedBox(
  1181. height: 26,
  1182. child: LayoutBuilder(
  1183. builder: (_, constraints) {
  1184. final colW = constraints.maxWidth / 3;
  1185. final bidBarW = (colW * bidDepth).clamp(0.0, colW);
  1186. final askBarW = (colW * askDepth).clamp(0.0, colW);
  1187. return Stack(
  1188. children: [
  1189. // 买盘深度条(左列,绿色)
  1190. if (bid != null)
  1191. Positioned(
  1192. left: 0,
  1193. top: 0,
  1194. bottom: 0,
  1195. child: Container(
  1196. width: bidBarW,
  1197. color: const Color(0xFF23D2A1).withAlpha(25),
  1198. ),
  1199. ),
  1200. // 卖盘深度条(右列,红色)
  1201. if (ask != null)
  1202. Positioned(
  1203. right: 0,
  1204. top: 0,
  1205. bottom: 0,
  1206. child: Container(
  1207. width: askBarW,
  1208. color: const Color(0xFFFF767B).withAlpha(25),
  1209. ),
  1210. ),
  1211. // 文字内容
  1212. Padding(
  1213. padding: const EdgeInsets.symmetric(horizontal: 12),
  1214. child: Row(
  1215. children: [
  1216. // 左列:买入数量(绿色)
  1217. Expanded(
  1218. child: Text(
  1219. bid != null
  1220. ? formatAmount(bid!.amount, decimals: qtyDecimals)
  1221. : '',
  1222. style: const TextStyle(
  1223. color: AppColors.rise,
  1224. fontSize: 11,
  1225. fontFeatures: [FontFeature.tabularFigures()],
  1226. ),
  1227. ),
  1228. ),
  1229. // 中列:买价 + 卖价(窄屏时用 scaleDown 避免横向溢出)
  1230. Expanded(
  1231. child: FittedBox(
  1232. fit: BoxFit.scaleDown,
  1233. child: Row(
  1234. mainAxisAlignment: MainAxisAlignment.center,
  1235. mainAxisSize: MainAxisSize.min,
  1236. children: [
  1237. if (bid != null)
  1238. Text(
  1239. priceDecimalPlaces != null
  1240. ? formatPrice(bid!.price,
  1241. decimalPlaces: priceDecimalPlaces)
  1242. : formatPrice(bid!.price),
  1243. style: const TextStyle(
  1244. color: AppColors.rise,
  1245. fontSize: 11,
  1246. fontWeight: FontWeight.w600,
  1247. fontFeatures: [FontFeature.tabularFigures()],
  1248. ),
  1249. ),
  1250. if (bid != null && ask != null)
  1251. const SizedBox(width: 4),
  1252. if (ask != null)
  1253. Text(
  1254. priceDecimalPlaces != null
  1255. ? formatPrice(ask!.price,
  1256. decimalPlaces: priceDecimalPlaces)
  1257. : formatPrice(ask!.price),
  1258. style: const TextStyle(
  1259. color: AppColors.fall,
  1260. fontSize: 11,
  1261. fontWeight: FontWeight.w600,
  1262. fontFeatures: [FontFeature.tabularFigures()],
  1263. ),
  1264. ),
  1265. ],
  1266. ),
  1267. ),
  1268. ),
  1269. // 右列:卖出数量(灰色)
  1270. Expanded(
  1271. child: Text(
  1272. ask != null
  1273. ? formatAmount(ask!.amount, decimals: qtyDecimals)
  1274. : '',
  1275. textAlign: TextAlign.right,
  1276. style: TextStyle(
  1277. color: cs.onSurface.withAlpha(153),
  1278. fontSize: 11,
  1279. fontFeatures: const [FontFeature.tabularFigures()],
  1280. ),
  1281. ),
  1282. ),
  1283. ],
  1284. ),
  1285. ),
  1286. ],
  1287. );
  1288. },
  1289. ),
  1290. );
  1291. }
  1292. }
  1293. // ── K 线图水印 ────────────────────────────────────────────
  1294. class _KlineWatermark extends StatelessWidget {
  1295. const _KlineWatermark();
  1296. @override
  1297. Widget build(BuildContext context) {
  1298. final isDark = Theme.of(context).brightness == Brightness.dark;
  1299. return IgnorePointer(
  1300. child: Align(
  1301. alignment: const Alignment(0, -0.5),
  1302. child: Opacity(
  1303. opacity: isDark ? 0.35 : 0.15,
  1304. child: Image.asset(
  1305. isDark ? 'assets/images/C2.png' : 'assets/images/C1.png',
  1306. width: 120,
  1307. ),
  1308. ),
  1309. ),
  1310. );
  1311. }
  1312. }
  1313. // ── 深度图面板(使用 k_chart_plus DepthChart)─────────────
  1314. class _DepthChartPanel extends ConsumerWidget {
  1315. const _DepthChartPanel({required this.marketKey});
  1316. final MarketDetailKey marketKey;
  1317. /// 买盘预累计量
  1318. /// bids 按价格降序传入(index 0 = 最优买价)。
  1319. /// 绘制器期望 index 0 在左端(最差价、最大累计量),index last 在右端中间价附近(最优价、最小累计量)。
  1320. /// 因此先累计再 reversed:最终 [最差价/最大量, ..., 最优价/最小量]
  1321. static List<DepthEntity> _toBidDepth(List<OrderBookEntry> bids) {
  1322. double cum = 0;
  1323. final result = bids.map((e) {
  1324. cum += e.amount;
  1325. return DepthEntity(e.price, cum);
  1326. }).toList();
  1327. return result.reversed.toList();
  1328. }
  1329. /// 卖盘预累计量(价格升序,累计从最低价开始)
  1330. static List<DepthEntity> _toAskDepth(List<OrderBookEntry> asks) {
  1331. double cum = 0;
  1332. return asks.map((e) {
  1333. cum += e.amount;
  1334. return DepthEntity(e.price, cum);
  1335. }).toList();
  1336. }
  1337. @override
  1338. Widget build(BuildContext context, WidgetRef ref) {
  1339. final cs = Theme.of(context).colorScheme;
  1340. final isFutures = marketKey.isFutures;
  1341. final OrderBook? orderBook;
  1342. var baseUnit = 4;
  1343. var quoteUnit = 2;
  1344. if (isFutures) {
  1345. orderBook = ref.watch(
  1346. marketDetailProvider(marketKey).select((s) => s.orderBook),
  1347. );
  1348. } else {
  1349. final spot = ref.watch(spotProvider(marketKey.symbol));
  1350. orderBook = spotRawDepthToOrderBook(
  1351. rawAsks: spot.orderBookAsks,
  1352. rawBids: spot.orderBookBids,
  1353. depthPrecision: spot.depth2Pre,
  1354. );
  1355. baseUnit = spot.volumePrecision;
  1356. quoteUnit = spot.depth2Pre;
  1357. }
  1358. if (orderBook == null || orderBook.bids.isEmpty || orderBook.asks.isEmpty) {
  1359. return SizedBox(
  1360. height: 230,
  1361. child: Center(
  1362. child: Text(AppLocalizations.of(context)!.noDepthData,
  1363. style:
  1364. TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
  1365. ),
  1366. );
  1367. }
  1368. final bids = _toBidDepth(orderBook.bids);
  1369. final asks = _toAskDepth(orderBook.asks);
  1370. final depthColors = ChartColors(
  1371. bgColor: cs.surface,
  1372. upColor: AppColors.rise,
  1373. dnColor: AppColors.fall,
  1374. gridColor: cs.outline,
  1375. defaultTextColor: cs.onSurface.withAlpha(153),
  1376. depthBuyColor: AppColors.rise,
  1377. depthSellColor: AppColors.fall,
  1378. );
  1379. return Column(
  1380. crossAxisAlignment: CrossAxisAlignment.start,
  1381. children: [
  1382. // 图例
  1383. Padding(
  1384. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  1385. child: Row(
  1386. children: [
  1387. _DepthLegendDot(color: AppColors.rise),
  1388. const SizedBox(width: 4),
  1389. Text(AppLocalizations.of(context)!.buy,
  1390. style: TextStyle(
  1391. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1392. const SizedBox(width: 16),
  1393. _DepthLegendDot(color: AppColors.fall),
  1394. const SizedBox(width: 4),
  1395. Text(AppLocalizations.of(context)!.sell,
  1396. style: TextStyle(
  1397. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1398. ],
  1399. ),
  1400. ),
  1401. SizedBox(
  1402. height: 260,
  1403. child: DepthChart(
  1404. bids,
  1405. asks,
  1406. depthColors,
  1407. baseUnit: baseUnit,
  1408. quoteUnit: quoteUnit,
  1409. ),
  1410. ),
  1411. ],
  1412. );
  1413. }
  1414. }
  1415. class _DepthLegendDot extends StatelessWidget {
  1416. const _DepthLegendDot({required this.color});
  1417. final Color color;
  1418. @override
  1419. Widget build(BuildContext context) {
  1420. return Container(
  1421. width: 8,
  1422. height: 8,
  1423. decoration: BoxDecoration(color: color, shape: BoxShape.circle),
  1424. );
  1425. }
  1426. }
  1427. // ── 最新成交面板 ─────────────────────────────────────────
  1428. /// 按照设计稿:表头(时间 / 价格(USDT) / 数量(BTC))+ 成交列表
  1429. /// 通过 .select((s) => s.trades) 独立订阅,订单簿变化不触发此面板重建
  1430. class _RecentTradesPanel extends ConsumerWidget {
  1431. const _RecentTradesPanel({required this.marketKey});
  1432. final MarketDetailKey marketKey;
  1433. @override
  1434. Widget build(BuildContext context, WidgetRef ref) {
  1435. final symbol = marketKey.symbol;
  1436. final isFutures = marketKey.isFutures;
  1437. final cs = Theme.of(context).colorScheme;
  1438. final List<RecentTrade> trades;
  1439. var qtyDecimals = 2;
  1440. if (isFutures) {
  1441. trades = ref.watch(
  1442. marketDetailProvider(marketKey).select((s) => s.trades),
  1443. );
  1444. } else {
  1445. final pub = ref.watch(
  1446. spotProvider(symbol).select((s) => s.recentPublicTrades),
  1447. );
  1448. qtyDecimals = ref.watch(
  1449. spotProvider(symbol).select((s) => s.volumePrecision),
  1450. );
  1451. trades = pub
  1452. .map(
  1453. (t) => RecentTrade(
  1454. price: t.price,
  1455. quantity: t.quantity,
  1456. isBuyerMaker: t.isBuyerMaker,
  1457. time: t.time,
  1458. tradeId: t.tradeId,
  1459. ),
  1460. )
  1461. .toList();
  1462. }
  1463. final base = symbol
  1464. .replaceAll('USDT', '')
  1465. .replaceAll('PERP', '')
  1466. .replaceAll('/', '');
  1467. return Column(
  1468. children: [
  1469. // 表头
  1470. Padding(
  1471. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  1472. child: Row(
  1473. children: [
  1474. Expanded(
  1475. child: Text(AppLocalizations.of(context)!.timeLabel2,
  1476. style: TextStyle(
  1477. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1478. ),
  1479. Expanded(
  1480. child: Text(AppLocalizations.of(context)!.priceLabel,
  1481. textAlign: TextAlign.center,
  1482. style: TextStyle(
  1483. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1484. ),
  1485. Expanded(
  1486. child: Text(AppLocalizations.of(context)!.amountLabel2(base),
  1487. textAlign: TextAlign.right,
  1488. style: TextStyle(
  1489. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  1490. ),
  1491. ],
  1492. ),
  1493. ),
  1494. // 成交列表
  1495. if (trades.isEmpty)
  1496. Padding(
  1497. padding: const EdgeInsets.symmetric(vertical: 30),
  1498. child: Center(
  1499. child: Text(AppLocalizations.of(context)!.noTradeData,
  1500. style: TextStyle(
  1501. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  1502. ),
  1503. )
  1504. else
  1505. ...trades.take(20).map(
  1506. (t) => _TradeRow(trade: t, qtyDecimals: qtyDecimals),
  1507. ),
  1508. ],
  1509. );
  1510. }
  1511. }
  1512. class _TradeRow extends StatelessWidget {
  1513. const _TradeRow({required this.trade, this.qtyDecimals = 2});
  1514. final RecentTrade trade;
  1515. final int qtyDecimals;
  1516. @override
  1517. Widget build(BuildContext context) {
  1518. final cs = Theme.of(context).colorScheme;
  1519. // 主动卖出(isBuyerMaker=true)显示红色,主动买入显示绿色
  1520. final color = trade.isBuyerMaker ? AppColors.fall : AppColors.rise;
  1521. final time = DateTime.fromMillisecondsSinceEpoch(trade.time);
  1522. final timeStr = '${time.hour.toString().padLeft(2, '0')}:'
  1523. '${time.minute.toString().padLeft(2, '0')}:'
  1524. '${time.second.toString().padLeft(2, '0')}';
  1525. return Padding(
  1526. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
  1527. child: Row(
  1528. children: [
  1529. Expanded(
  1530. child: Text(
  1531. timeStr,
  1532. style:
  1533. TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  1534. ),
  1535. ),
  1536. Expanded(
  1537. child: Text(
  1538. formatPrice(trade.price),
  1539. textAlign: TextAlign.center,
  1540. style: TextStyle(
  1541. color: color, fontSize: 12, fontWeight: FontWeight.w500),
  1542. ),
  1543. ),
  1544. Expanded(
  1545. child: Text(
  1546. formatAmount(trade.quantity, decimals: qtyDecimals),
  1547. textAlign: TextAlign.right,
  1548. style: TextStyle(color: cs.onSurface, fontSize: 12),
  1549. ),
  1550. ),
  1551. ],
  1552. ),
  1553. );
  1554. }
  1555. }
  1556. // ── 底部买入/卖出 或 开多/开空 按钮 ─────────────────────────────────────
  1557. class _BottomActions extends StatelessWidget {
  1558. const _BottomActions({required this.marketKey});
  1559. final MarketDetailKey marketKey;
  1560. @override
  1561. Widget build(BuildContext context) {
  1562. final symbol = marketKey.symbol;
  1563. final isFutures = marketKey.isFutures;
  1564. final cs = Theme.of(context).colorScheme;
  1565. final isDark = Theme.of(context).brightness == Brightness.dark;
  1566. final l10n = AppLocalizations.of(context)!;
  1567. final buyLabel = isFutures ? l10n.openLong : l10n.buy;
  1568. final sellLabel = isFutures ? l10n.openShort : l10n.sell;
  1569. final route = isFutures ? '/futures/$symbol' : '/spot/$symbol';
  1570. return Container(
  1571. padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
  1572. decoration: BoxDecoration(
  1573. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  1574. border: Border(top: BorderSide(color: cs.outline)),
  1575. ),
  1576. child: Row(
  1577. children: [
  1578. Expanded(
  1579. child: ElevatedButton(
  1580. onPressed: () => context.go(route),
  1581. style: ElevatedButton.styleFrom(
  1582. backgroundColor: AppColors.rise,
  1583. foregroundColor: Colors.white,
  1584. minimumSize: const Size(0, 46),
  1585. shape: RoundedRectangleBorder(
  1586. borderRadius: BorderRadius.circular(8)),
  1587. elevation: 0,
  1588. ),
  1589. child: Text(buyLabel,
  1590. style: const TextStyle(
  1591. fontSize: 16, fontWeight: FontWeight.w600)),
  1592. ),
  1593. ),
  1594. const SizedBox(width: 12),
  1595. Expanded(
  1596. child: ElevatedButton(
  1597. onPressed: () => context.go(route),
  1598. style: ElevatedButton.styleFrom(
  1599. backgroundColor: AppColors.fall,
  1600. foregroundColor: Colors.white,
  1601. minimumSize: const Size(0, 46),
  1602. shape: RoundedRectangleBorder(
  1603. borderRadius: BorderRadius.circular(8)),
  1604. elevation: 0,
  1605. ),
  1606. child: Text(sellLabel,
  1607. style: const TextStyle(
  1608. fontSize: 16, fontWeight: FontWeight.w600)),
  1609. ),
  1610. ),
  1611. ],
  1612. ),
  1613. );
  1614. }
  1615. }
  1616. // ══════════════════════════════════════════════════════════════
  1617. // 概览 Tab(币种信息 + 关键数据)
  1618. // ══════════════════════════════════════════════════════════════
  1619. class _InfoTab extends ConsumerWidget {
  1620. const _InfoTab({required this.marketKey});
  1621. final MarketDetailKey marketKey;
  1622. String _formatPrice(double? v) {
  1623. if (v == null) return '--';
  1624. // 移除多余尾零,保留有效精度
  1625. final s = v.toStringAsFixed(8).replaceAll(RegExp(r'\.?0+$'), '');
  1626. return '\$$s';
  1627. }
  1628. @override
  1629. Widget build(BuildContext context, WidgetRef ref) {
  1630. final symbol = marketKey.symbol;
  1631. final cs = Theme.of(context).colorScheme;
  1632. final isDark = Theme.of(context).brightness == Brightness.dark;
  1633. final baseAsset =
  1634. symbol.toUpperCase().replaceAll('USDT', '').replaceAll('PERP', '');
  1635. final coinExt = ref.watch(
  1636. marketDetailProvider(marketKey).select((s) => s.coinExt),
  1637. );
  1638. // 从 API 数据构建关键数据行(无数据时展示 --)
  1639. List<(String, String, String?)> buildInfoRows() {
  1640. final l10n = AppLocalizations.of(context)!;
  1641. return [
  1642. (l10n.rank, coinExt?.rank != null ? 'No.${coinExt!.rank}' : '--', null),
  1643. (l10n.marketCap, coinExt?.marketCap ?? '--', null),
  1644. (l10n.circulatingSupply, coinExt?.circulatingSupply ?? '--', null),
  1645. (l10n.issuePrice, _formatPrice(coinExt?.issuePrice), null),
  1646. (l10n.athPrice, _formatPrice(coinExt?.athPrice), coinExt?.athDate),
  1647. ];
  1648. }
  1649. final info = buildInfoRows();
  1650. final coinName = coinExt?.nameCn ?? baseAsset;
  1651. final coinNameEn = coinExt?.nameEn ?? '';
  1652. final iconUrl = coinExt?.icon;
  1653. return SingleChildScrollView(
  1654. physics: const ClampingScrollPhysics(),
  1655. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  1656. child: Column(
  1657. crossAxisAlignment: CrossAxisAlignment.start,
  1658. children: [
  1659. // ── 币种信息卡 ──────────────────────────────────
  1660. Container(
  1661. padding: const EdgeInsets.all(14),
  1662. decoration: BoxDecoration(
  1663. color: isDark
  1664. ? AppColors.darkBgSecondary
  1665. : AppColors.lightBgSecondary,
  1666. borderRadius: BorderRadius.circular(12),
  1667. ),
  1668. child: Row(
  1669. children: [
  1670. // 圆形图标(优先网络图片,失败降级为字母)
  1671. CoinIcon(
  1672. symbol: baseAsset,
  1673. iconUrl: iconUrl ?? '',
  1674. size: 40,
  1675. shape: BoxShape.circle,
  1676. ),
  1677. const SizedBox(width: 12),
  1678. Column(
  1679. crossAxisAlignment: CrossAxisAlignment.start,
  1680. children: [
  1681. Row(
  1682. children: [
  1683. Text(
  1684. baseAsset,
  1685. style: TextStyle(
  1686. color: cs.onSurface,
  1687. fontSize: 16,
  1688. fontWeight: FontWeight.w700,
  1689. ),
  1690. ),
  1691. if (coinName.isNotEmpty && coinName != baseAsset) ...[
  1692. const SizedBox(width: 6),
  1693. Text(
  1694. '($coinName)',
  1695. style: TextStyle(
  1696. color: cs.onSurface.withAlpha(153),
  1697. fontSize: 14,
  1698. ),
  1699. ),
  1700. ],
  1701. ],
  1702. ),
  1703. if (coinNameEn.isNotEmpty) ...[
  1704. const SizedBox(height: 2),
  1705. Text(
  1706. coinNameEn,
  1707. style: TextStyle(
  1708. color: cs.onSurface.withAlpha(153),
  1709. fontSize: 12,
  1710. ),
  1711. ),
  1712. ],
  1713. ],
  1714. ),
  1715. ],
  1716. ),
  1717. ),
  1718. if (info.isNotEmpty) ...[
  1719. const SizedBox(height: 20),
  1720. // ── 关键数据 ──────────────────────────────────
  1721. Text(
  1722. AppLocalizations.of(context)!.keyData,
  1723. style: TextStyle(
  1724. color: cs.onSurface,
  1725. fontSize: 16,
  1726. fontWeight: FontWeight.w600,
  1727. ),
  1728. ),
  1729. const SizedBox(height: 8),
  1730. Container(
  1731. decoration: BoxDecoration(
  1732. color: isDark
  1733. ? AppColors.darkBgSecondary
  1734. : AppColors.lightBgSecondary,
  1735. borderRadius: BorderRadius.circular(12),
  1736. ),
  1737. child: Column(
  1738. children: [
  1739. for (var i = 0; i < info.length; i++) ...[
  1740. if (i > 0)
  1741. Divider(
  1742. color: cs.outline,
  1743. height: 1,
  1744. indent: 16,
  1745. endIndent: 16,
  1746. ),
  1747. _InfoRow(
  1748. label: info[i].$1,
  1749. value: info[i].$2,
  1750. subValue: info[i].$3,
  1751. ),
  1752. ],
  1753. ],
  1754. ),
  1755. ),
  1756. ],
  1757. const SizedBox(height: 20),
  1758. // ── 免责声明 ──────────────────────────────────
  1759. Center(
  1760. child: Text(
  1761. AppLocalizations.of(context)!.dataDisclaimer,
  1762. style: TextStyle(
  1763. color: cs.onSurface.withAlpha(120),
  1764. fontSize: 11,
  1765. ),
  1766. ),
  1767. ),
  1768. const SizedBox(height: 16),
  1769. ],
  1770. ),
  1771. );
  1772. }
  1773. }
  1774. // ── 关键数据行 ──────────────────────────────────────────────
  1775. class _InfoRow extends StatelessWidget {
  1776. const _InfoRow({
  1777. required this.label,
  1778. required this.value,
  1779. this.subValue,
  1780. });
  1781. final String label;
  1782. final String value;
  1783. final String? subValue;
  1784. @override
  1785. Widget build(BuildContext context) {
  1786. final cs = Theme.of(context).colorScheme;
  1787. return Padding(
  1788. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  1789. child: Row(
  1790. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  1791. children: [
  1792. Text(
  1793. label,
  1794. style: TextStyle(
  1795. color: cs.onSurface.withAlpha(153),
  1796. fontSize: 14,
  1797. ),
  1798. ),
  1799. Column(
  1800. crossAxisAlignment: CrossAxisAlignment.end,
  1801. children: [
  1802. Text(
  1803. value,
  1804. style: TextStyle(
  1805. color: cs.onSurface,
  1806. fontSize: 14,
  1807. fontWeight: FontWeight.w500,
  1808. ),
  1809. ),
  1810. if (subValue != null)
  1811. Text(
  1812. subValue!,
  1813. style: TextStyle(
  1814. color: cs.onSurface.withAlpha(150),
  1815. fontSize: 11,
  1816. ),
  1817. ),
  1818. ],
  1819. ),
  1820. ],
  1821. ),
  1822. );
  1823. }
  1824. }
  1825. // ══════════════════════════════════════════════════════════════
  1826. // 数据 Tab(资金费率图表 + 历史记录)
  1827. // ══════════════════════════════════════════════════════════════
  1828. class _DataTab extends ConsumerWidget {
  1829. const _DataTab({required this.symbol});
  1830. final String symbol;
  1831. @override
  1832. Widget build(BuildContext context, WidgetRef ref) {
  1833. final contractCoinId = ref.watch(
  1834. futuresProvider(symbol).select((s) => s.contractCoinId),
  1835. );
  1836. if (contractCoinId <= 0) {
  1837. return const Center(child: CircularProgressIndicator());
  1838. }
  1839. final state = ref.watch(fundingRateProvider(contractCoinId));
  1840. final l10n = AppLocalizations.of(context)!;
  1841. final cs = Theme.of(context).colorScheme;
  1842. if (state.isLoading) {
  1843. return const Center(child: CircularProgressIndicator());
  1844. }
  1845. if (state.error != null && state.history.isEmpty) {
  1846. return Center(
  1847. child: Text(l10n.loadFailed,
  1848. style: TextStyle(color: cs.onSurface.withAlpha(153))),
  1849. );
  1850. }
  1851. final current = state.current;
  1852. final history = state.history;
  1853. final ratePct = current != null
  1854. ? '${current.rate >= 0 ? '+' : ''}${(current.rate * 100).toStringAsFixed(4)}%'
  1855. : '--';
  1856. // 下次结算倒计时(使用 futuresProvider 的 fundingCountdown,已由 WS 驱动)
  1857. final countdown = ref.watch(
  1858. futuresProvider(symbol).select((s) => s.fundingCountdown),
  1859. );
  1860. // 去掉 ScrollView 的横向 padding,改为对各区块单独加 padding,
  1861. // 图表本身不加,实现全宽贴边显示
  1862. const hPad = EdgeInsets.symmetric(horizontal: 16);
  1863. return SingleChildScrollView(
  1864. physics: const ClampingScrollPhysics(),
  1865. padding: const EdgeInsets.only(top: 16, bottom: 16),
  1866. child: Column(
  1867. crossAxisAlignment: CrossAxisAlignment.start,
  1868. children: [
  1869. // ── 当前资金费率 ──────────────────────────────────
  1870. Padding(
  1871. padding: hPad,
  1872. child: RichText(
  1873. text: TextSpan(
  1874. children: [
  1875. TextSpan(
  1876. text: '${l10n.fundingRate}:',
  1877. style: TextStyle(
  1878. color: cs.onSurface.withAlpha(180),
  1879. fontSize: 14,
  1880. ),
  1881. ),
  1882. TextSpan(
  1883. text: ratePct,
  1884. style: TextStyle(
  1885. color: current != null && current.rate >= 0
  1886. ? AppColors.rise
  1887. : AppColors.fall,
  1888. fontSize: 14,
  1889. fontWeight: FontWeight.w600,
  1890. ),
  1891. ),
  1892. ],
  1893. ),
  1894. ),
  1895. ),
  1896. const SizedBox(height: 12),
  1897. // ── 折线图(全宽,不加横向 padding)────────────────
  1898. if (history.isNotEmpty)
  1899. _FundingRateChart(history: history)
  1900. else
  1901. Container(
  1902. height: 180,
  1903. alignment: Alignment.center,
  1904. child: Text(l10n.noData,
  1905. style: TextStyle(color: cs.onSurface.withAlpha(120))),
  1906. ),
  1907. const SizedBox(height: 16),
  1908. // ── 时间周期 & 倒计时 ────────────────────────────
  1909. Padding(
  1910. padding: hPad,
  1911. child: Column(
  1912. crossAxisAlignment: CrossAxisAlignment.start,
  1913. children: [
  1914. _infoRow(context, l10n.timePeriod, '8H'),
  1915. Divider(color: cs.outline.withAlpha(80), height: 1),
  1916. _infoRow(context, l10n.nextFundingCountdown, countdown),
  1917. Divider(color: cs.outline, height: 1),
  1918. const SizedBox(height: 12),
  1919. // ── 历史表头 ──────────────────────────────────────
  1920. _HistoryHeader(l10n: l10n),
  1921. const SizedBox(height: 4),
  1922. // ── 历史列表 ──────────────────────────────────────
  1923. if (history.isEmpty)
  1924. Padding(
  1925. padding: const EdgeInsets.symmetric(vertical: 24),
  1926. child: Center(
  1927. child: Text(l10n.noData,
  1928. style: TextStyle(color: cs.onSurface.withAlpha(120))),
  1929. ),
  1930. )
  1931. else
  1932. ...history.reversed.map((item) => _HistoryRow(item: item)),
  1933. ],
  1934. ),
  1935. ),
  1936. ],
  1937. ),
  1938. );
  1939. }
  1940. Widget _infoRow(BuildContext context, String label, String value) {
  1941. final cs = Theme.of(context).colorScheme;
  1942. return Padding(
  1943. padding: const EdgeInsets.symmetric(vertical: 12),
  1944. child: Row(
  1945. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  1946. children: [
  1947. Text(label,
  1948. style:
  1949. TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  1950. Text(value, style: TextStyle(color: cs.onSurface, fontSize: 14)),
  1951. ],
  1952. ),
  1953. );
  1954. }
  1955. }
  1956. // ── 资金费率折线图 ────────────────────────────────────────
  1957. class _FundingRateChart extends StatelessWidget {
  1958. const _FundingRateChart({required this.history});
  1959. final List<FundingRateHistoryItem> history;
  1960. @override
  1961. Widget build(BuildContext context) {
  1962. final cs = Theme.of(context).colorScheme;
  1963. final isDark = Theme.of(context).brightness == Brightness.dark;
  1964. final items = history;
  1965. if (items.isEmpty) return const SizedBox.shrink();
  1966. final rates = items.map((e) => e.rate).toList();
  1967. final minRate = rates.reduce((a, b) => a < b ? a : b);
  1968. final maxRate = rates.reduce((a, b) => a > b ? a : b);
  1969. final rangePad = (maxRate - minRate) * 0.25;
  1970. final yMin = minRate - rangePad;
  1971. final yMax = maxRate + rangePad;
  1972. final spots = items.asMap().entries.map((e) {
  1973. return FlSpot(e.key.toDouble(), e.value.rate);
  1974. }).toList();
  1975. // 与 K 线图保持一致的配色
  1976. final bgColor = isDark ? AppColors.darkBg : AppColors.lightBg;
  1977. final gridColor = cs.outline.withAlpha(50);
  1978. final textColor =
  1979. isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary;
  1980. const lineColor = AppColors.chartLineBlue;
  1981. final tooltipBg =
  1982. isDark ? AppColors.darkBgSecondary : const Color(0xFF3A3A3C);
  1983. // x 轴标签间距:最多显示 5 个
  1984. final xInterval = (items.length / 5).floorToDouble().clamp(1.0, 999.0);
  1985. return Container(
  1986. height: 220,
  1987. color: bgColor,
  1988. padding: const EdgeInsets.only(top: 8, bottom: 2, left: 4),
  1989. child: LineChart(
  1990. LineChartData(
  1991. minY: yMin,
  1992. maxY: yMax,
  1993. clipData: const FlClipData.all(),
  1994. gridData: FlGridData(
  1995. show: true,
  1996. drawVerticalLine: true,
  1997. verticalInterval: xInterval,
  1998. horizontalInterval: (yMax - yMin) / 4,
  1999. getDrawingHorizontalLine: (_) =>
  2000. FlLine(color: gridColor, strokeWidth: 0.5),
  2001. getDrawingVerticalLine: (_) =>
  2002. FlLine(color: gridColor, strokeWidth: 0.5),
  2003. ),
  2004. borderData: FlBorderData(show: false),
  2005. titlesData: FlTitlesData(
  2006. leftTitles:
  2007. const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  2008. topTitles:
  2009. const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  2010. rightTitles: AxisTitles(
  2011. sideTitles: SideTitles(
  2012. showTitles: true,
  2013. reservedSize: 58,
  2014. getTitlesWidget: (val, meta) {
  2015. // 只在网格线位置显示,跳过首尾极值
  2016. if (val == meta.min || val == meta.max) {
  2017. return const SizedBox.shrink();
  2018. }
  2019. final pct = (val * 100).toStringAsFixed(3);
  2020. return Padding(
  2021. padding: const EdgeInsets.only(left: 4),
  2022. child: Text('$pct%',
  2023. style: TextStyle(fontSize: 9, color: textColor)),
  2024. );
  2025. },
  2026. ),
  2027. ),
  2028. bottomTitles: AxisTitles(
  2029. sideTitles: SideTitles(
  2030. showTitles: true,
  2031. reservedSize: 18,
  2032. interval: xInterval,
  2033. getTitlesWidget: (val, _) {
  2034. final idx = val.toInt();
  2035. if (idx < 0 || idx >= items.length) {
  2036. return const SizedBox.shrink();
  2037. }
  2038. final date = items[idx].fundingTime;
  2039. return Text(
  2040. DateFormat('MM/dd').format(date),
  2041. style: TextStyle(fontSize: 9, color: textColor),
  2042. );
  2043. },
  2044. ),
  2045. ),
  2046. ),
  2047. // 零轴参考线(虚线),与 K 线图十字线风格对齐
  2048. extraLinesData: ExtraLinesData(
  2049. horizontalLines: [
  2050. HorizontalLine(
  2051. y: 0,
  2052. color: cs.outline.withAlpha(160),
  2053. strokeWidth: 0.8,
  2054. dashArray: [4, 4],
  2055. ),
  2056. ],
  2057. ),
  2058. lineTouchData: LineTouchData(
  2059. enabled: true,
  2060. // 十字线:与 K 线图 vCrossWidth / hCrossWidth 对齐
  2061. getTouchedSpotIndicator: (_, indices) => indices.map((_) {
  2062. return TouchedSpotIndicatorData(
  2063. FlLine(color: cs.onSurface.withAlpha(160), strokeWidth: 0.5),
  2064. FlDotData(
  2065. show: true,
  2066. getDotPainter: (_, __, ___, ____) => FlDotCirclePainter(
  2067. radius: 3.5,
  2068. color: lineColor,
  2069. strokeWidth: 1.5,
  2070. strokeColor: Colors.white,
  2071. ),
  2072. ),
  2073. );
  2074. }).toList(),
  2075. touchTooltipData: LineTouchTooltipData(
  2076. getTooltipColor: (_) => tooltipBg,
  2077. tooltipRoundedRadius: 4,
  2078. tooltipPadding:
  2079. const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
  2080. getTooltipItems: (touchedSpots) {
  2081. return touchedSpots.map((spot) {
  2082. final idx = spot.spotIndex;
  2083. if (idx < 0 || idx >= items.length) return null;
  2084. final item = items[idx];
  2085. final dateStr =
  2086. DateFormat('MM/dd HH:mm').format(item.fundingTime);
  2087. final rateStr =
  2088. '${item.rate >= 0 ? '+' : ''}${(item.rate * 100).toStringAsFixed(4)}%';
  2089. final rateColor =
  2090. item.rate >= 0 ? AppColors.rise : AppColors.fall;
  2091. return LineTooltipItem(
  2092. '$dateStr\n',
  2093. TextStyle(
  2094. color: isDark
  2095. ? AppColors.darkTextSecondary
  2096. : Colors.white70,
  2097. fontSize: 10),
  2098. children: [
  2099. TextSpan(
  2100. text: rateStr,
  2101. style: TextStyle(
  2102. color: rateColor,
  2103. fontSize: 11,
  2104. fontWeight: FontWeight.w600),
  2105. ),
  2106. ],
  2107. );
  2108. }).toList();
  2109. },
  2110. ),
  2111. touchCallback: (_, __) {},
  2112. handleBuiltInTouches: true,
  2113. ),
  2114. lineBarsData: [
  2115. LineChartBarData(
  2116. spots: spots,
  2117. isCurved: true,
  2118. curveSmoothness: 0.25,
  2119. color: lineColor,
  2120. barWidth: 1.5,
  2121. dotData: const FlDotData(show: false),
  2122. // 线下渐变填充,与 K 线图折线模式风格一致
  2123. belowBarData: BarAreaData(
  2124. show: true,
  2125. gradient: LinearGradient(
  2126. begin: Alignment.topCenter,
  2127. end: Alignment.bottomCenter,
  2128. colors: [
  2129. lineColor.withAlpha(60),
  2130. lineColor.withAlpha(0),
  2131. ],
  2132. ),
  2133. ),
  2134. ),
  2135. ],
  2136. ),
  2137. ),
  2138. );
  2139. }
  2140. }
  2141. // ── 历史表头 & 行 ─────────────────────────────────────────
  2142. class _HistoryHeader extends StatelessWidget {
  2143. const _HistoryHeader({required this.l10n});
  2144. final AppLocalizations l10n;
  2145. @override
  2146. Widget build(BuildContext context) {
  2147. final cs = Theme.of(context).colorScheme;
  2148. final style = TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12);
  2149. return Row(
  2150. children: [
  2151. Expanded(child: Text(l10n.timeLabel, style: style)),
  2152. Expanded(
  2153. child: Text(l10n.fundingRate,
  2154. style: style, textAlign: TextAlign.right)),
  2155. ],
  2156. );
  2157. }
  2158. }
  2159. class _HistoryRow extends StatelessWidget {
  2160. const _HistoryRow({required this.item});
  2161. final FundingRateHistoryItem item;
  2162. @override
  2163. Widget build(BuildContext context) {
  2164. final cs = Theme.of(context).colorScheme;
  2165. final rateStr =
  2166. '${item.rate >= 0 ? '+' : ''}${(item.rate * 100).toStringAsFixed(4)}%';
  2167. final rateColor = item.rate >= 0 ? AppColors.rise : AppColors.fall;
  2168. final timeStr = DateFormat('yyyy-MM-dd HH:mm').format(item.fundingTime);
  2169. return Padding(
  2170. padding: const EdgeInsets.symmetric(vertical: 10),
  2171. child: Row(
  2172. children: [
  2173. Expanded(
  2174. child: Text(
  2175. timeStr,
  2176. style: TextStyle(color: cs.onSurface, fontSize: 12),
  2177. ),
  2178. ),
  2179. Text(
  2180. rateStr,
  2181. style: TextStyle(color: rateColor, fontSize: 12),
  2182. ),
  2183. ],
  2184. ),
  2185. );
  2186. }
  2187. }
  2188. // ── 行情详情骨架屏 ─────────────────────────────────────────
  2189. class _MarketDetailShimmer extends StatelessWidget {
  2190. const _MarketDetailShimmer();
  2191. @override
  2192. Widget build(BuildContext context) {
  2193. return AppShimmer(
  2194. child: SingleChildScrollView(
  2195. physics: const NeverScrollableScrollPhysics(),
  2196. child: Column(
  2197. crossAxisAlignment: CrossAxisAlignment.start,
  2198. children: [
  2199. // 行情/概览 Tab 占位
  2200. Container(
  2201. height: 44,
  2202. padding: const EdgeInsets.symmetric(horizontal: 16),
  2203. child: Row(
  2204. children: [
  2205. shimmerBox(32, 16),
  2206. const SizedBox(width: 24),
  2207. shimmerBox(32, 16),
  2208. ],
  2209. ),
  2210. ),
  2211. // 价格头部
  2212. Padding(
  2213. padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
  2214. child: Column(
  2215. crossAxisAlignment: CrossAxisAlignment.start,
  2216. children: [
  2217. shimmerBox(160, 32, radius: 6),
  2218. const SizedBox(height: 8),
  2219. Row(
  2220. children: [
  2221. shimmerBox(80, 12),
  2222. const SizedBox(width: 8),
  2223. shimmerBox(50, 12),
  2224. const SizedBox(width: 8),
  2225. shimmerBox(90, 12),
  2226. ],
  2227. ),
  2228. const SizedBox(height: 12),
  2229. Row(
  2230. children: List.generate(
  2231. 3,
  2232. (col) => Expanded(
  2233. child: Column(
  2234. crossAxisAlignment: col == 2
  2235. ? CrossAxisAlignment.end
  2236. : col == 1
  2237. ? CrossAxisAlignment.center
  2238. : CrossAxisAlignment.start,
  2239. children: [
  2240. shimmerBox(48, 10),
  2241. const SizedBox(height: 4),
  2242. shimmerBox(60, 12),
  2243. const SizedBox(height: 8),
  2244. shimmerBox(48, 10),
  2245. const SizedBox(height: 4),
  2246. shimmerBox(60, 12),
  2247. ],
  2248. ),
  2249. )),
  2250. ),
  2251. ],
  2252. ),
  2253. ),
  2254. const SizedBox(height: 4),
  2255. // 周期 Tab 占位
  2256. SizedBox(
  2257. height: 36,
  2258. child: ListView.separated(
  2259. scrollDirection: Axis.horizontal,
  2260. padding: const EdgeInsets.symmetric(horizontal: 12),
  2261. physics: const NeverScrollableScrollPhysics(),
  2262. itemCount: 7,
  2263. separatorBuilder: (_, __) => const SizedBox(width: 16),
  2264. itemBuilder: (_, __) => Center(child: shimmerBox(28, 14)),
  2265. ),
  2266. ),
  2267. const SizedBox(height: 6),
  2268. // K 线图区域占位
  2269. shimmerFill(340, radius: 0),
  2270. // 指标栏
  2271. Padding(
  2272. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  2273. child: Row(
  2274. children: List.generate(
  2275. 4,
  2276. (i) => Padding(
  2277. padding: const EdgeInsets.only(right: 12),
  2278. child: shimmerBox(28, 14),
  2279. )),
  2280. ),
  2281. ),
  2282. const SizedBox(height: 4),
  2283. // 历史收益率行
  2284. Padding(
  2285. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  2286. child: Row(
  2287. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  2288. children: List.generate(
  2289. 6,
  2290. (_) => Column(
  2291. children: [
  2292. shimmerBox(24, 10),
  2293. const SizedBox(height: 4),
  2294. shimmerBox(32, 12),
  2295. ],
  2296. )),
  2297. ),
  2298. ),
  2299. const SizedBox(height: 8),
  2300. // 订单簿 Tab 行
  2301. Padding(
  2302. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  2303. child: Row(
  2304. children: [
  2305. shimmerBox(48, 14),
  2306. const SizedBox(width: 24),
  2307. shimmerBox(48, 14),
  2308. const SizedBox(width: 24),
  2309. shimmerBox(36, 14),
  2310. ],
  2311. ),
  2312. ),
  2313. // 订单簿表头
  2314. Padding(
  2315. padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
  2316. child: Row(
  2317. children: [
  2318. Expanded(child: shimmerBox(24, 10)),
  2319. Expanded(child: Center(child: shimmerBox(56, 10))),
  2320. Expanded(
  2321. child: Align(
  2322. alignment: Alignment.centerRight,
  2323. child: shimmerBox(24, 10))),
  2324. ],
  2325. ),
  2326. ),
  2327. // 订单簿数据行
  2328. ...List.generate(
  2329. 10,
  2330. (_) => Padding(
  2331. padding: const EdgeInsets.symmetric(
  2332. horizontal: 12, vertical: 5),
  2333. child: Row(
  2334. children: [
  2335. Expanded(child: shimmerBox(52, 12)),
  2336. Expanded(child: Center(child: shimmerBox(80, 12))),
  2337. Expanded(
  2338. child: Align(
  2339. alignment: Alignment.centerRight,
  2340. child: shimmerBox(52, 12))),
  2341. ],
  2342. ),
  2343. )),
  2344. const SizedBox(height: 16),
  2345. ],
  2346. ),
  2347. ),
  2348. );
  2349. }
  2350. }