| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503 |
- import 'dart:io';
- import 'dart:ui' as ui;
- import 'package:fl_chart/fl_chart.dart';
- import 'package:flutter/material.dart';
- import 'package:flutter/rendering.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import 'package:intl/intl.dart';
- import 'package:k_chart_plus/chart_translations.dart';
- import 'package:k_chart_plus/k_chart_plus.dart';
- import 'package:path_provider/path_provider.dart';
- import 'package:share_plus/share_plus.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/number_format.dart';
- import '../../../core/utils/symbol_display.dart';
- import '../../../core/utils/spot_order_book_convert.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../data/models/market/funding_rate.dart';
- import '../../../data/models/market/order_book_entry.dart';
- import '../../../providers/funding_rate_provider.dart';
- import '../../../providers/futures_provider.dart';
- import '../../../providers/market_detail_provider.dart';
- import '../../../providers/spot_provider.dart';
- import '../../widgets/common/app_shimmer.dart';
- import '../../widgets/common/coin_icon.dart';
- import '../../widgets/common/symbol_picker_sheet.dart';
- /// K 线详情页骨架(现货 / 永续分别用 [SpotMarketDetailScreen]、[FuturesMarketDetailScreen] 入口)。
- class MarketDetailScaffold extends ConsumerStatefulWidget {
- const MarketDetailScaffold({super.key, required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- ConsumerState<MarketDetailScaffold> createState() =>
- _MarketDetailScaffoldState();
- }
- class _MarketDetailScaffoldState extends ConsumerState<MarketDetailScaffold> {
- final _shareKey = GlobalKey();
- bool _sharing = false;
- @override
- void initState() {
- super.initState();
- // provider 无 autoDispose,state 跨页面保留;进入页面时强制重置到"行情"tab
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- ref.read(marketDetailProvider(widget.marketKey).notifier).setTopTab(0);
- });
- }
- Future<void> _handleShare() async {
- if (_sharing) return;
- if (!mounted) return;
- setState(() => _sharing = true);
- try {
- final boundary = _shareKey.currentContext?.findRenderObject()
- as RenderRepaintBoundary?;
- if (boundary == null) return;
- final image = await boundary.toImage(pixelRatio: 2.5);
- final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
- final bytes = byteData!.buffer.asUint8List();
- final tempDir = await getTemporaryDirectory();
- final file = File(
- '${tempDir.path}/market_${DateTime.now().millisecondsSinceEpoch}.png');
- await file.writeAsBytes(bytes);
- // 先重置状态,再调用分享(避免用户取消时系统面板不 resolve 导致按钮卡转圈)
- if (mounted) setState(() => _sharing = false);
- Share.shareXFiles([XFile(file.path)], text: widget.marketKey.symbol);
- } catch (_) {
- if (mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.shareFailed,
- backgroundColor: AppColors.fall);
- }
- } finally {
- if (mounted) setState(() => _sharing = false);
- }
- }
- @override
- Widget build(BuildContext context) {
- final key = widget.marketKey;
- final isLoading = ref.watch(
- marketDetailProvider(key).select((s) => s.isLoading),
- );
- return Scaffold(
- appBar: _SymbolAppBar(
- marketKey: key,
- onShare: _handleShare,
- sharing: _sharing,
- ),
- body: RepaintBoundary(
- key: _shareKey,
- child: isLoading
- ? const _MarketDetailShimmer()
- : _DetailBody(marketKey: key),
- ),
- bottomNavigationBar: _BottomActions(marketKey: key),
- );
- }
- }
- // ── 顶部 AppBar(含币对切换)─────────────────────────────────
- class _SymbolAppBar extends ConsumerWidget implements PreferredSizeWidget {
- const _SymbolAppBar({
- required this.marketKey,
- required this.onShare,
- this.sharing = false,
- });
- final MarketDetailKey marketKey;
- final VoidCallback onShare;
- final bool sharing;
- String get symbol => marketKey.symbol;
- bool get isFutures => marketKey.isFutures;
- @override
- Size get preferredSize => const Size.fromHeight(kToolbarHeight);
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- return AppBar(
- // 默认 titleSpacing 约 16,会在 leading 与 title 之间留出一块空白
- titleSpacing: 0,
- leadingWidth: 30,
- leading: IconButton(
- icon: const Icon(Icons.arrow_back_ios, size: 18),
- onPressed: () {
- if (context.canPop()) {
- context.pop();
- } else {
- context.go('/market');
- }
- },
- padding: EdgeInsets.zero,
- visualDensity: VisualDensity.compact,
- constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
- ),
- title: Semantics(
- label: 'market_detail_symbol_picker',
- button: true,
- onTap: () => _showSymbolPicker(context, ref),
- child: GestureDetector(
- onTap: () => _showSymbolPicker(context, ref),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(formatUsdtPairDisplay(symbol),
- style: const TextStyle(
- fontSize: 17, fontWeight: FontWeight.w600)),
- const SizedBox(width: 4),
- const Icon(Icons.keyboard_arrow_down, size: 18),
- ],
- ),
- ),
- ),
- actions: [
- Semantics(
- label: 'market_detail_btn_share',
- button: true,
- enabled: !sharing,
- onTap: onShare,
- child: IconButton(
- icon: sharing
- ? const SizedBox(
- width: 18,
- height: 18,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Icon(Icons.share_outlined, size: 20),
- onPressed: sharing ? null : onShare,
- ),
- ),
- ],
- );
- }
- void _showSymbolPicker(BuildContext context, WidgetRef ref) {
- showModalBottomSheet(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- backgroundColor: Theme.of(context).colorScheme.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (sheetCtx) => SymbolPickerSheet(
- currentSymbol: symbol,
- initialTab: isFutures ? SymbolPickerTab.futures : SymbolPickerTab.spot,
- visibleTabs: [
- if (isFutures) SymbolPickerTab.futures else SymbolPickerTab.spot,
- ],
- onSelected: (newSymbol) {
- Navigator.pop(sheetCtx);
- if (newSymbol != symbol) {
- context.replace(isFutures
- ? '/market/futures/$newSymbol'
- : '/market/spot/$newSymbol');
- }
- },
- onSpotSelected: (newSymbol) {
- Navigator.pop(sheetCtx);
- if (newSymbol != symbol) {
- context.replace(isFutures
- ? '/market/futures/$newSymbol'
- : '/market/spot/$newSymbol');
- }
- },
- ),
- );
- }
- }
- // ── 详情主体 ──────────────────────────────────────────────
- // 页面主体:只传 symbol,各子组件自行 select 需要的数据。
- // 按 topTab 切换行情/概览内容,未激活的 tab 不构建。
- class _DetailBody extends ConsumerWidget {
- const _DetailBody({required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final symbol = marketKey.symbol;
- final topTab = ref.watch(
- marketDetailProvider(marketKey).select((s) => s.topTab),
- );
- Widget content;
- if (topTab == 0) {
- content = _MarketContent(marketKey: marketKey);
- } else if (topTab == 1) {
- content = _InfoTab(marketKey: marketKey);
- } else {
- content = _DataTab(symbol: symbol);
- }
- return Column(
- children: [
- _TopTabs(marketKey: marketKey),
- Expanded(child: content),
- ],
- );
- }
- }
- // 整个页面可上下滚动。K 线图用固定高度 450。
- // KChartWidget 内部用 onHorizontalDrag 处理水平拖拽,
- // Flutter 手势竞技场自动区分水平/垂直 — 垂直滑动归 SingleChildScrollView。
- // 不需要动态切换 physics。
- class _MarketContent extends StatefulWidget {
- const _MarketContent({required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- State<_MarketContent> createState() => _MarketContentState();
- }
- class _MarketContentState extends State<_MarketContent> {
- final _dismissNotifier = ValueNotifier<bool>(false);
- // 用于判断点击位置是否在 K 线图区域内
- final _chartKey = GlobalKey();
- @override
- void dispose() {
- _dismissNotifier.dispose();
- super.dispose();
- }
- /// 点击 K 线图区域外部才触发 dismiss;
- /// 图表内部的 tap 由 KChartWidget 自身处理(展示/切换蜡烛详情)。
- void _onTapDown(TapDownDetails details) {
- final ro = _chartKey.currentContext?.findRenderObject() as RenderBox?;
- if (ro != null) {
- final local = ro.globalToLocal(details.globalPosition);
- if (local.dx >= 0 &&
- local.dx <= ro.size.width &&
- local.dy >= 0 &&
- local.dy <= ro.size.height) {
- return; // 点击在图表内部,交给 KChartWidget 处理
- }
- }
- _dismissNotifier.value = !_dismissNotifier.value;
- }
- @override
- Widget build(BuildContext context) {
- return GestureDetector(
- behavior: HitTestBehavior.translucent,
- onTapDown: _onTapDown,
- child: SingleChildScrollView(
- physics: const ClampingScrollPhysics(),
- child: Column(
- children: [
- _PriceHeader(marketKey: widget.marketKey),
- _PeriodTabBar(marketKey: widget.marketKey),
- SizedBox(
- key: _chartKey,
- child: _KlineChartArea(
- marketKey: widget.marketKey,
- dismissNotifier: _dismissNotifier)),
- _OrderBookSection(marketKey: widget.marketKey),
- const SizedBox(height: 16),
- ],
- ),
- ),
- );
- }
- }
- // ── 顶部 Tab(价格/信息)────────────────────────────────────
- // 只 select topTab,行情/概览切换不触发其他区域重建
- class _TopTabs extends ConsumerWidget {
- const _TopTabs({required this.marketKey});
- final MarketDetailKey marketKey;
- List<String> _getLabels(BuildContext context) {
- final l10n = AppLocalizations.of(context)!;
- final tabs = [l10n.market, l10n.marketOverview];
- if (marketKey.isFutures) tabs.add(l10n.marketData);
- return tabs;
- }
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final provider = marketDetailProvider(marketKey);
- final topTab = ref.watch(provider.select((s) => s.topTab));
- final notifier = ref.read(provider.notifier);
- final cs = Theme.of(context).colorScheme;
- final semanticsLabels = [
- 'market_detail_tab_chart',
- 'market_detail_tab_overview',
- if (marketKey.isFutures) 'market_detail_tab_data',
- ];
- return Container(
- decoration: BoxDecoration(
- border: Border(bottom: BorderSide(color: cs.outline)),
- ),
- child: Row(
- children: List.generate(_getLabels(context).length, (i) {
- final selected = i == topTab;
- return Semantics(
- label: semanticsLabels[i],
- button: true,
- enabled: true,
- onTap: () => notifier.setTopTab(i),
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: () => notifier.setTopTab(i),
- child: Padding(
- padding: const EdgeInsets.only(left: 16, right: 8),
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 6),
- child: Text(
- _getLabels(context)[i],
- style: TextStyle(
- fontSize: 14,
- color: selected
- ? cs.onSurface
- : cs.onSurface.withAlpha(153),
- fontWeight:
- selected ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- // 未选中时用透明占位保持高度一致,避免切换时布局跳动
- Container(
- height: 2,
- width: 24,
- color: selected ? AppColors.brand : Colors.transparent,
- ),
- ],
- ),
- ),
- ),
- );
- }),
- ),
- );
- }
- }
- // ── 价格区 ────────────────────────────────────────────────
- // 只 select stats,K线更新不会触发价格区重建
- class _PriceHeader extends ConsumerWidget {
- const _PriceHeader({required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final symbol = marketKey.symbol;
- final isFutures = marketKey.isFutures;
- final cs = Theme.of(context).colorScheme;
- final stats = ref.watch(
- marketDetailProvider(marketKey).select((s) => s.stats),
- );
- if (stats == null) return const SizedBox.shrink();
- final priceColor = AppColors.changeColor(stats.change24h);
- final sign = stats.change24h >= 0 ? '+' : '';
- // 提取基础币名(BTCUSDT → BTC)
- final baseCoin = symbol.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- return Padding(
- padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 大价格:用主文字色,禁止跟随涨跌色(规范 1.2 节)
- Text(
- stats.lastPriceStr != null
- ? formatRawPrice(stats.lastPriceStr!)
- : formatPrice(stats.lastPrice),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 26,
- fontWeight: FontWeight.w700,
- letterSpacing: -0.5,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
- const SizedBox(height: 2),
- // 副行:≈$xxx | 涨跌幅 |(合约:标记价格)
- Row(
- children: [
- Flexible(
- child: Text(
- '≈ \$${formatPrice(stats.lastPrice)}',
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11),
- ),
- ),
- const SizedBox(width: 8),
- Text(
- '$sign${stats.change24h.toStringAsFixed(2)}%',
- style: TextStyle(
- color: priceColor,
- fontSize: 11,
- fontWeight: FontWeight.w500),
- ),
- if (isFutures) ...[
- const SizedBox(width: 8),
- Flexible(
- child: Text(
- '${AppLocalizations.of(context)!.markPrice} ${formatPrice(stats.markPrice)}',
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11),
- ),
- ),
- ],
- ],
- ),
- const SizedBox(height: 8),
- // 合约:3 列(含资金费率/倒计时);现货:2 列
- Row(
- children: [
- // 列1:最高/最低
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _StatItem(
- label: AppLocalizations.of(context)!.high24h,
- value: formatPrice(stats.high24h),
- color: AppColors.rise),
- const SizedBox(height: 6),
- _StatItem(
- label: AppLocalizations.of(context)!.low24h,
- value: formatPrice(stats.low24h),
- color: AppColors.fall),
- ],
- ),
- ),
- // 列2:成交量/额
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _StatItem(
- label: AppLocalizations.of(context)!
- .volume24hLabel(baseCoin),
- value: stats.volume24h >= 1000
- ? '${(stats.volume24h / 1000).toStringAsFixed(2)}K'
- : stats.volume24h.toStringAsFixed(2),
- ),
- const SizedBox(height: 6),
- _StatItem(
- label: AppLocalizations.of(context)!.turnover24hLabel,
- value: stats.turnover24h >= 1e6
- ? '${(stats.turnover24h / 1e6).toStringAsFixed(2)}M'
- : '${(stats.turnover24h / 1000).toStringAsFixed(2)}K',
- ),
- ],
- ),
- ),
- if (isFutures)
- Expanded(
- child: _FundingColumn(symbol: symbol),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- // 资金费率 + 倒计时列(仅永续合约行情页展示)
- class _FundingColumn extends ConsumerWidget {
- const _FundingColumn({required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final fundingRate = ref.watch(
- futuresProvider(symbol).select((s) => s.fundingRate),
- );
- final fundingCountdown = ref.watch(
- futuresProvider(symbol).select((s) => s.fundingCountdown),
- );
- final sign = fundingRate >= 0 ? '+' : '';
- final rate = '$sign${(fundingRate * 100).toStringAsFixed(4)}%';
- final countdown = fundingCountdown;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- _StatItem(
- label: AppLocalizations.of(context)!.fundingRate,
- value: rate,
- align: TextAlign.right),
- const SizedBox(height: 6),
- _StatItem(
- label: AppLocalizations.of(context)!.countdown,
- value: countdown,
- align: TextAlign.right),
- ],
- );
- }
- }
- class _StatItem extends StatelessWidget {
- const _StatItem(
- {required this.label, required this.value, this.color, this.align});
- final String label;
- final String value;
- final Color? color;
- final TextAlign? align;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Column(
- crossAxisAlignment: align == TextAlign.right
- ? CrossAxisAlignment.end
- : CrossAxisAlignment.start,
- children: [
- Text(label,
- textAlign: align,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
- const SizedBox(height: 1),
- Text(
- value,
- textAlign: align,
- style: TextStyle(
- color: color ?? cs.onSurface,
- fontSize: 11,
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- );
- }
- }
- String _klinePeriodLabel(KlinePeriod p, AppLocalizations l10n) {
- switch (p) {
- case KlinePeriod.min1:
- return l10n.klinePeriod1m;
- case KlinePeriod.min5:
- return l10n.klinePeriod5m;
- case KlinePeriod.min15:
- return l10n.klinePeriod15m;
- case KlinePeriod.min30:
- return l10n.klinePeriod30m;
- case KlinePeriod.hour1:
- return l10n.klinePeriod1h;
- case KlinePeriod.hour4:
- return l10n.klinePeriod4h;
- case KlinePeriod.day1:
- return l10n.klinePeriod1d;
- case KlinePeriod.week1:
- return l10n.klinePeriod1w;
- case KlinePeriod.month1:
- return l10n.klinePeriod1mon;
- }
- }
- // ── 周期 Tab ──────────────────────────────────────────────
- // 只 select period,其他字段变化不触发重建
- class _PeriodTabBar extends ConsumerWidget {
- const _PeriodTabBar({required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- final period = ref.watch(
- marketDetailProvider(marketKey).select((s) => s.period),
- );
- final onChanged =
- ref.read(marketDetailProvider(marketKey).notifier).setPeriod;
- // 主栏只展示前4个周期,其余通过"更多"选择
- const mainPeriods = [
- KlinePeriod.min1,
- KlinePeriod.min5,
- KlinePeriod.min15,
- KlinePeriod.hour1,
- KlinePeriod.day1
- ];
- final inMore = !mainPeriods.contains(period);
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
- decoration: BoxDecoration(
- border: Border(bottom: BorderSide(color: cs.outline)),
- ),
- child: Row(
- children: [
- ...mainPeriods.map((p) {
- final selected = p == period;
- return Semantics(
- label: 'market_detail_period_${p.wsInterval}',
- button: true,
- enabled: true,
- onTap: () => onChanged(p),
- child: GestureDetector(
- onTap: () => onChanged(p),
- child: Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
- child: Text(
- _klinePeriodLabel(p, l10n),
- style: TextStyle(
- fontSize: 13,
- color: selected
- ? AppColors.brand
- : cs.onSurface.withAlpha(153),
- fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- ),
- );
- }),
- const Spacer(),
- GestureDetector(
- onTap: () => _showMorePeriods(context, period, onChanged, l10n),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- inMore ? _klinePeriodLabel(period, l10n) : l10n.more,
- style: TextStyle(
- fontSize: 13,
- color: inMore
- ? AppColors.brand
- : cs.onSurface.withAlpha(153),
- fontWeight: inMore ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- Icon(Icons.keyboard_arrow_down,
- size: 14,
- color: inMore
- ? AppColors.brand
- : cs.onSurface.withAlpha(153)),
- ],
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
- void _showMorePeriods(
- BuildContext context,
- KlinePeriod current,
- void Function(KlinePeriod) onChanged,
- AppLocalizations l10n,
- ) {
- final cs = Theme.of(context).colorScheme;
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- constraints: const BoxConstraints(maxWidth: double.infinity),
- backgroundColor: Theme.of(context).colorScheme.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (_) {
- return SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- const SizedBox(height: 12),
- Center(
- child: Container(
- width: 36,
- height: 4,
- decoration: BoxDecoration(
- color: cs.outline,
- borderRadius: BorderRadius.circular(2),
- )),
- ),
- const SizedBox(height: 16),
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Wrap(
- spacing: 12,
- runSpacing: 12,
- children: KlinePeriod.values.map((p) {
- final selected = p == current;
- return GestureDetector(
- onTap: () {
- onChanged(p);
- Navigator.of(context).pop();
- },
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 20, vertical: 10),
- decoration: BoxDecoration(
- color: selected
- ? AppColors.brand.withAlpha(30)
- : cs.surface,
- border: Border.all(
- color: selected ? AppColors.brand : cs.outline,
- ),
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- _klinePeriodLabel(p, l10n),
- style: TextStyle(
- fontSize: 14,
- color: selected ? AppColors.brand : cs.onSurface,
- fontWeight:
- selected ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- );
- }).toList(),
- ),
- ),
- const SizedBox(height: 24),
- ],
- ),
- );
- },
- );
- }
- // ── K 线图区域(使用 k_chart_plus)──────────────────────
- class _KlineChartArea extends ConsumerStatefulWidget {
- const _KlineChartArea({required this.marketKey, this.dismissNotifier});
- final MarketDetailKey marketKey;
- final ValueNotifier<bool>? dismissNotifier;
- @override
- ConsumerState<_KlineChartArea> createState() => _KlineChartAreaState();
- }
- class _KlineChartAreaState extends ConsumerState<_KlineChartArea> {
- // 主图指标(MA/BOLL/SAR 可多选)—— 默认只选 MA
- Set<MainState> _mainStates = {MainState.MA};
- // 副图指标(MACD/KDJ/RSI/WR/CCI 可多选)—— 默认全不选
- Set<SecondaryState> _secondaryStates = {};
- bool _volHidden = false;
- /// 去掉 WS 原始价格字符串的尾零,用于图表右侧标签(不加千分符)
- static String _stripRawPrice(String s) {
- final parts = s.split('.');
- if (parts.length < 2) return s;
- final decimal = parts[1].replaceAll(RegExp(r'0+$'), '');
- return decimal.isEmpty ? parts[0] : '${parts[0]}.$decimal';
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final provider = marketDetailProvider(widget.marketKey);
- final entities = ref.watch(provider.select((s) => s.entities));
- final nowPriceStr = ref.watch(provider.select((s) => s.stats?.lastPriceStr));
- final notifier = ref.read(provider.notifier);
- if (entities.isEmpty) {
- final isLoading = ref.watch(provider.select((s) => s.isLoading));
- return SizedBox(
- height: 350,
- child: Center(
- child: isLoading
- ? const CircularProgressIndicator(strokeWidth: 2)
- : Text(AppLocalizations.of(context)!.noKlineData,
- style: TextStyle(
- color: cs.onSurface.withAlpha(128), fontSize: 14)),
- ),
- );
- }
- // 动态计算图表高度:主图 + VOL(20%) + 副图(20%×数量) + 标签(12×数量)
- const baseH = 260.0;
- final volH = _volHidden ? 0.0 : baseH * 0.2;
- final secH = baseH * 0.2 * _secondaryStates.length;
- final labelH = 12.0 * _mainStates.length;
- final chartH = baseH + volH + secH + labelH;
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // K 线图(高度随指标数量动态变化)
- SizedBox(
- height: chartH,
- child: Stack(
- children: [
- KChartWidget(
- entities,
- _chartStyle,
- _buildChartColors(cs, isDark),
- isTrendLine: false,
- isTapShowInfoDialog: true,
- dismissInfoNotifier: widget.dismissNotifier,
- mainStateLi: _mainStates,
- secondaryStateLi: _secondaryStates,
- volHidden: _volHidden,
- showNowPrice: true,
- maDayList: const [5, 10, 20],
- mBaseHeight: baseH,
- fixedLength: 2,
- nowPriceStr: nowPriceStr != null
- ? _stripRawPrice(nowPriceStr)
- : null,
- timeFormat: const [
- yyyy,
- '-',
- mm,
- '-',
- dd,
- ' ',
- HH,
- ':',
- nn,
- ':',
- ss
- ],
- chartTranslations: _buildChartTranslations(context),
- verticalTextAlignment: VerticalTextAlignment.right,
- onLoadMore: (isLeft) {
- if (!isLeft) notifier.loadMoreKlines();
- },
- isOnDrag: (isDrag) {},
- ),
- // 水印:logo(去黑底)+ 文字,居中半透明叠加
- const _KlineWatermark(),
- ],
- ),
- ),
- // 指标选择器(主图 + 副图)
- _buildIndicatorBar(),
- ],
- );
- }
- /// 指标切换栏:MA BOLL SAR | VOL MACD KDJ RSI WR
- Widget _buildIndicatorBar() {
- final cs = Theme.of(context).colorScheme;
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- border: Border(top: BorderSide(color: cs.outline)),
- ),
- child: SingleChildScrollView(
- scrollDirection: Axis.horizontal,
- child: Row(
- children: [
- // 主图指标
- ..._mainIndicators.map((e) => _indicatorChip(
- label: e.$1,
- active: _mainStates.contains(e.$2),
- onTap: () => setState(() {
- if (_mainStates.contains(e.$2)) {
- _mainStates = Set.from(_mainStates)..remove(e.$2);
- } else {
- _mainStates = Set.from(_mainStates)..add(e.$2);
- }
- }),
- )),
- // 分隔线
- Container(
- width: 1,
- height: 16,
- color: cs.outline,
- margin: const EdgeInsets.symmetric(horizontal: 6)),
- // VOL 开关
- _indicatorChip(
- label: 'VOL',
- active: !_volHidden,
- onTap: () => setState(() => _volHidden = !_volHidden),
- ),
- // 副图指标
- ..._secondaryIndicators.map((e) => _indicatorChip(
- label: e.$1,
- active: _secondaryStates.contains(e.$2),
- onTap: () => setState(() {
- if (_secondaryStates.contains(e.$2)) {
- _secondaryStates = Set.from(_secondaryStates)
- ..remove(e.$2);
- } else {
- _secondaryStates = Set.from(_secondaryStates)..add(e.$2);
- }
- }),
- )),
- ],
- ),
- ),
- );
- }
- Widget _indicatorChip({
- required String label,
- required bool active,
- required VoidCallback onTap,
- }) {
- final cs = Theme.of(context).colorScheme;
- return GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: onTap,
- child: Container(
- margin: const EdgeInsets.symmetric(horizontal: 4),
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
- decoration: BoxDecoration(
- color: active ? AppColors.brand.withAlpha(30) : Colors.transparent,
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(
- label,
- style: TextStyle(
- fontSize: 12,
- color: active ? AppColors.brand : cs.onSurface.withAlpha(153),
- fontWeight: active ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- );
- }
- static const _mainIndicators = [
- ('MA', MainState.MA),
- ('BOLL', MainState.BOLL),
- ('SAR', MainState.SAR),
- ];
- static const _secondaryIndicators = [
- ('MACD', SecondaryState.MACD),
- ('KDJ', SecondaryState.KDJ),
- ('RSI', SecondaryState.RSI),
- ('WR', SecondaryState.WR),
- ('CCI', SecondaryState.CCI),
- ];
- // ── 主题感知图表配色 ────────────────────────────────
- ChartColors _buildChartColors(ColorScheme cs, bool isDark) => ChartColors(
- bgColor: isDark ? AppColors.darkBg : AppColors.lightBg,
- ma5Color: AppColors.chartMa5,
- ma10Color: AppColors.chartMa10,
- ma30Color: AppColors.chartMa30,
- upColor: AppColors.rise,
- dnColor: AppColors.fall,
- volColor: AppColors.chartLineBlue,
- macdColor: AppColors.chartLineBlue,
- difColor: AppColors.chartMa10,
- deaColor: AppColors.chartMa30,
- nowPriceUpColor: AppColors.rise,
- nowPriceDnColor: AppColors.fall,
- nowPriceTextColor: Colors.white,
- gridColor: cs.outline,
- kLineColor: AppColors.chartLineBlue,
- selectBorderColor: cs.outline,
- selectFillColor:
- isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- defaultTextColor:
- isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
- maxColor: cs.onSurface,
- minColor: cs.onSurface,
- infoWindowNormalColor: cs.onSurface,
- infoWindowTitleColor:
- isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary,
- infoWindowUpColor: AppColors.rise,
- infoWindowDnColor: AppColors.fall,
- hCrossColor: cs.onSurface,
- vCrossColor: cs.onSurface,
- crossTextColor: cs.onSurface,
- );
- static final _chartStyle = ChartStyle()
- ..childPadding = 12.0 // 主图与 VOL/副图之间的间距
- ..bottomPadding = 20.0 // 底部留给时间轴标签
- ..vCrossWidth = 0.5 // 十字线竖线宽度(默认 8.5 过粗)
- ..hCrossWidth = 0.5; // 十字线横线宽度
- ChartTranslations _buildChartTranslations(BuildContext context) {
- final l10n = AppLocalizations.of(context)!;
- return ChartTranslations(
- date: l10n.klineDate,
- open: l10n.klineOpen,
- high: l10n.klineHigh,
- low: l10n.klineLow,
- close: l10n.klineClose,
- changeAmount: l10n.klineChangeAmt,
- change: l10n.klineChange,
- amount: l10n.klineAmount,
- vol: l10n.klineVol,
- );
- }
- }
- // ── 订单簿区域 ────────────────────────────────────────────
- // 只 select orderBook 和 bottomTab,K线/价格变化不触发订单簿重建
- class _OrderBookSection extends ConsumerWidget {
- const _OrderBookSection({required this.marketKey});
- final MarketDetailKey marketKey;
- List<String> _getTabs(BuildContext context) {
- final l10n = AppLocalizations.of(context)!;
- return [l10n.orderBook, l10n.latestTrades, l10n.depthChart];
- }
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final symbol = marketKey.symbol;
- final isFutures = marketKey.isFutures;
- final cs = Theme.of(context).colorScheme;
- final provider = marketDetailProvider(marketKey);
- final OrderBook? orderBook;
- int? obPriceDecimals;
- int obQtyDecimals;
- if (isFutures) {
- orderBook = ref.watch(provider.select((s) => s.orderBook));
- obPriceDecimals = null;
- obQtyDecimals = 4;
- } else {
- final spot = ref.watch(spotProvider(symbol));
- orderBook = spotRawDepthToOrderBook(
- rawAsks: spot.orderBookAsks,
- rawBids: spot.orderBookBids,
- depthPrecision: spot.depth2Pre,
- );
- obPriceDecimals = spot.depth2Pre;
- obQtyDecimals = spot.volumePrecision;
- }
- final tabIndex = ref.watch(provider.select((s) => s.bottomTab));
- final onTabChanged = ref.read(provider.notifier).setBottomTab;
- final tabs = _getTabs(context);
- return Column(
- children: [
- // Tab 行
- Container(
- decoration: BoxDecoration(
- border: Border(bottom: BorderSide(color: cs.outline)),
- ),
- child: Row(
- children: List.generate(tabs.length, (i) {
- final selected = i == tabIndex;
- return GestureDetector(
- behavior: HitTestBehavior.opaque, // 整个 padding 区域都响应点击
- onTap: () => onTabChanged(i),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 14),
- child: Text(
- tabs[i],
- style: TextStyle(
- fontSize: 14,
- color: selected
- ? cs.onSurface
- : cs.onSurface.withAlpha(153),
- fontWeight:
- selected ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- Container(
- height: 2,
- width: 24,
- color: selected ? AppColors.brand : Colors.transparent,
- ),
- ],
- ),
- ),
- );
- }),
- ),
- ),
- // 按 tab 切换内容
- if (tabIndex == 0) ...[
- // ── 订单簿 ────────────────────────────────
- _MergedOrderBook(
- orderBook: orderBook,
- priceDecimalPlaces: obPriceDecimals,
- qtyDecimals: obQtyDecimals,
- ),
- ] else if (tabIndex == 1) ...[
- // ── 最新成交 ──────────────────────────────
- _RecentTradesPanel(marketKey: marketKey),
- ] else if (tabIndex == 2) ...[
- // ── 深度图 ────────────────────────────────
- _DepthChartPanel(marketKey: marketKey),
- ],
- ],
- );
- }
- }
- // ── 合并订单簿(每行同时显示买盘/卖盘配对)─────────────────────
- // 原型设计:买入qty | 买价 卖价 | 卖出qty,每行对应同一档位索引
- class _MergedOrderBook extends StatelessWidget {
- const _MergedOrderBook({
- required this.orderBook,
- this.priceDecimalPlaces,
- this.qtyDecimals = 4,
- });
- final OrderBook? orderBook;
- /// 现货:与交易对 `depth2Pre` 一致;合约为 null 走 formatPrice 默认规则
- final int? priceDecimalPlaces;
- final int qtyDecimals;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- if (orderBook == null) {
- return const SizedBox(
- height: 240,
- child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
- );
- }
- final ob = orderBook!;
- // bids 按价格降序(index 0 = 最优买价),asks 按价格升序(index 0 = 最优卖价)
- final bids = ob.bids.take(12).toList();
- final asks = ob.asks.take(12).toList();
- final rowCount = bids.length > asks.length ? bids.length : asks.length;
- final maxBidAmt = bids.isEmpty
- ? 1.0
- : bids.map((e) => e.amount).fold(0.0, (a, b) => a > b ? a : b);
- final maxAskAmt = asks.isEmpty
- ? 1.0
- : asks.map((e) => e.amount).fold(0.0, (a, b) => a > b ? a : b);
- return Column(
- children: [
- // 表头
- Padding(
- padding: const EdgeInsets.fromLTRB(12, 6, 12, 2),
- child: Row(
- children: [
- Expanded(
- child: Text(AppLocalizations.of(context)!.buy,
- style: TextStyle(color: AppColors.rise, fontSize: 10)),
- ),
- Expanded(
- child: Text(
- AppLocalizations.of(context)!.priceLabel,
- textAlign: TextAlign.center,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 10),
- ),
- ),
- Expanded(
- child: Text(
- AppLocalizations.of(context)!.sell,
- textAlign: TextAlign.right,
- style: TextStyle(color: AppColors.fall, fontSize: 10),
- ),
- ),
- ],
- ),
- ),
- // 数据行:买卖配对展示
- for (int i = 0; i < rowCount; i++)
- _PairedRow(
- bid: i < bids.length ? bids[i] : null,
- ask: i < asks.length ? asks[i] : null,
- bidDepth: i < bids.length
- ? (bids[i].amount / maxBidAmt).clamp(0.0, 1.0)
- : 0.0,
- askDepth: i < asks.length
- ? (asks[i].amount / maxAskAmt).clamp(0.0, 1.0)
- : 0.0,
- priceDecimalPlaces: priceDecimalPlaces,
- qtyDecimals: qtyDecimals,
- ),
- ],
- );
- }
- }
- class _PairedRow extends StatelessWidget {
- const _PairedRow({
- this.bid,
- this.ask,
- required this.bidDepth,
- required this.askDepth,
- this.priceDecimalPlaces,
- this.qtyDecimals = 4,
- });
- final OrderBookEntry? bid;
- final OrderBookEntry? ask;
- final double bidDepth;
- final double askDepth;
- final int? priceDecimalPlaces;
- final int qtyDecimals;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return SizedBox(
- height: 26,
- child: LayoutBuilder(
- builder: (_, constraints) {
- final colW = constraints.maxWidth / 3;
- final bidBarW = (colW * bidDepth).clamp(0.0, colW);
- final askBarW = (colW * askDepth).clamp(0.0, colW);
- return Stack(
- children: [
- // 买盘深度条(左列,绿色)
- if (bid != null)
- Positioned(
- left: 0,
- top: 0,
- bottom: 0,
- child: Container(
- width: bidBarW,
- color: const Color(0xFF23D2A1).withAlpha(25),
- ),
- ),
- // 卖盘深度条(右列,红色)
- if (ask != null)
- Positioned(
- right: 0,
- top: 0,
- bottom: 0,
- child: Container(
- width: askBarW,
- color: const Color(0xFFFF767B).withAlpha(25),
- ),
- ),
- // 文字内容
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: Row(
- children: [
- // 左列:买入数量(绿色)
- Expanded(
- child: Text(
- bid != null
- ? formatAmount(bid!.amount, decimals: qtyDecimals)
- : '',
- style: const TextStyle(
- color: AppColors.rise,
- fontSize: 11,
- fontFeatures: [FontFeature.tabularFigures()],
- ),
- ),
- ),
- // 中列:买价 + 卖价(窄屏时用 scaleDown 避免横向溢出)
- Expanded(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- mainAxisSize: MainAxisSize.min,
- children: [
- if (bid != null)
- Text(
- priceDecimalPlaces != null
- ? formatPrice(bid!.price,
- decimalPlaces: priceDecimalPlaces)
- : formatPrice(bid!.price),
- style: const TextStyle(
- color: AppColors.rise,
- fontSize: 11,
- fontWeight: FontWeight.w600,
- fontFeatures: [FontFeature.tabularFigures()],
- ),
- ),
- if (bid != null && ask != null)
- const SizedBox(width: 4),
- if (ask != null)
- Text(
- priceDecimalPlaces != null
- ? formatPrice(ask!.price,
- decimalPlaces: priceDecimalPlaces)
- : formatPrice(ask!.price),
- style: const TextStyle(
- color: AppColors.fall,
- fontSize: 11,
- fontWeight: FontWeight.w600,
- fontFeatures: [FontFeature.tabularFigures()],
- ),
- ),
- ],
- ),
- ),
- ),
- // 右列:卖出数量(灰色)
- Expanded(
- child: Text(
- ask != null
- ? formatAmount(ask!.amount, decimals: qtyDecimals)
- : '',
- textAlign: TextAlign.right,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 11,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- );
- },
- ),
- );
- }
- }
- // ── K 线图水印 ────────────────────────────────────────────
- class _KlineWatermark extends StatelessWidget {
- const _KlineWatermark();
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return IgnorePointer(
- child: Align(
- alignment: const Alignment(0, -0.5),
- child: Opacity(
- opacity: isDark ? 0.35 : 0.15,
- child: Image.asset(
- isDark ? 'assets/images/C2.png' : 'assets/images/C1.png',
- width: 120,
- ),
- ),
- ),
- );
- }
- }
- // ── 深度图面板(使用 k_chart_plus DepthChart)─────────────
- class _DepthChartPanel extends ConsumerWidget {
- const _DepthChartPanel({required this.marketKey});
- final MarketDetailKey marketKey;
- /// 买盘预累计量
- /// bids 按价格降序传入(index 0 = 最优买价)。
- /// 绘制器期望 index 0 在左端(最差价、最大累计量),index last 在右端中间价附近(最优价、最小累计量)。
- /// 因此先累计再 reversed:最终 [最差价/最大量, ..., 最优价/最小量]
- static List<DepthEntity> _toBidDepth(List<OrderBookEntry> bids) {
- double cum = 0;
- final result = bids.map((e) {
- cum += e.amount;
- return DepthEntity(e.price, cum);
- }).toList();
- return result.reversed.toList();
- }
- /// 卖盘预累计量(价格升序,累计从最低价开始)
- static List<DepthEntity> _toAskDepth(List<OrderBookEntry> asks) {
- double cum = 0;
- return asks.map((e) {
- cum += e.amount;
- return DepthEntity(e.price, cum);
- }).toList();
- }
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final isFutures = marketKey.isFutures;
- final OrderBook? orderBook;
- var baseUnit = 4;
- var quoteUnit = 2;
- if (isFutures) {
- orderBook = ref.watch(
- marketDetailProvider(marketKey).select((s) => s.orderBook),
- );
- } else {
- final spot = ref.watch(spotProvider(marketKey.symbol));
- orderBook = spotRawDepthToOrderBook(
- rawAsks: spot.orderBookAsks,
- rawBids: spot.orderBookBids,
- depthPrecision: spot.depth2Pre,
- );
- baseUnit = spot.volumePrecision;
- quoteUnit = spot.depth2Pre;
- }
- if (orderBook == null || orderBook.bids.isEmpty || orderBook.asks.isEmpty) {
- return SizedBox(
- height: 230,
- child: Center(
- child: Text(AppLocalizations.of(context)!.noDepthData,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
- ),
- );
- }
- final bids = _toBidDepth(orderBook.bids);
- final asks = _toAskDepth(orderBook.asks);
- final depthColors = ChartColors(
- bgColor: cs.surface,
- upColor: AppColors.rise,
- dnColor: AppColors.fall,
- gridColor: cs.outline,
- defaultTextColor: cs.onSurface.withAlpha(153),
- depthBuyColor: AppColors.rise,
- depthSellColor: AppColors.fall,
- );
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 图例
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
- child: Row(
- children: [
- _DepthLegendDot(color: AppColors.rise),
- const SizedBox(width: 4),
- Text(AppLocalizations.of(context)!.buy,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(width: 16),
- _DepthLegendDot(color: AppColors.fall),
- const SizedBox(width: 4),
- Text(AppLocalizations.of(context)!.sell,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ],
- ),
- ),
- SizedBox(
- height: 260,
- child: DepthChart(
- bids,
- asks,
- depthColors,
- baseUnit: baseUnit,
- quoteUnit: quoteUnit,
- ),
- ),
- ],
- );
- }
- }
- class _DepthLegendDot extends StatelessWidget {
- const _DepthLegendDot({required this.color});
- final Color color;
- @override
- Widget build(BuildContext context) {
- return Container(
- width: 8,
- height: 8,
- decoration: BoxDecoration(color: color, shape: BoxShape.circle),
- );
- }
- }
- // ── 最新成交面板 ─────────────────────────────────────────
- /// 按照设计稿:表头(时间 / 价格(USDT) / 数量(BTC))+ 成交列表
- /// 通过 .select((s) => s.trades) 独立订阅,订单簿变化不触发此面板重建
- class _RecentTradesPanel extends ConsumerWidget {
- const _RecentTradesPanel({required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final symbol = marketKey.symbol;
- final isFutures = marketKey.isFutures;
- final cs = Theme.of(context).colorScheme;
- final List<RecentTrade> trades;
- var qtyDecimals = 2;
- if (isFutures) {
- trades = ref.watch(
- marketDetailProvider(marketKey).select((s) => s.trades),
- );
- } else {
- final pub = ref.watch(
- spotProvider(symbol).select((s) => s.recentPublicTrades),
- );
- qtyDecimals = ref.watch(
- spotProvider(symbol).select((s) => s.volumePrecision),
- );
- trades = pub
- .map(
- (t) => RecentTrade(
- price: t.price,
- quantity: t.quantity,
- isBuyerMaker: t.isBuyerMaker,
- time: t.time,
- tradeId: t.tradeId,
- ),
- )
- .toList();
- }
- final base = symbol
- .replaceAll('USDT', '')
- .replaceAll('PERP', '')
- .replaceAll('/', '');
- return Column(
- children: [
- // 表头
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
- child: Row(
- children: [
- Expanded(
- child: Text(AppLocalizations.of(context)!.timeLabel2,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ),
- Expanded(
- child: Text(AppLocalizations.of(context)!.priceLabel,
- textAlign: TextAlign.center,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ),
- Expanded(
- child: Text(AppLocalizations.of(context)!.amountLabel2(base),
- textAlign: TextAlign.right,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ),
- ],
- ),
- ),
- // 成交列表
- if (trades.isEmpty)
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 30),
- child: Center(
- child: Text(AppLocalizations.of(context)!.noTradeData,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 13)),
- ),
- )
- else
- ...trades.take(20).map(
- (t) => _TradeRow(trade: t, qtyDecimals: qtyDecimals),
- ),
- ],
- );
- }
- }
- class _TradeRow extends StatelessWidget {
- const _TradeRow({required this.trade, this.qtyDecimals = 2});
- final RecentTrade trade;
- final int qtyDecimals;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- // 主动卖出(isBuyerMaker=true)显示红色,主动买入显示绿色
- final color = trade.isBuyerMaker ? AppColors.fall : AppColors.rise;
- final time = DateTime.fromMillisecondsSinceEpoch(trade.time);
- final timeStr = '${time.hour.toString().padLeft(2, '0')}:'
- '${time.minute.toString().padLeft(2, '0')}:'
- '${time.second.toString().padLeft(2, '0')}';
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
- child: Row(
- children: [
- Expanded(
- child: Text(
- timeStr,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
- ),
- ),
- Expanded(
- child: Text(
- formatPrice(trade.price),
- textAlign: TextAlign.center,
- style: TextStyle(
- color: color, fontSize: 12, fontWeight: FontWeight.w500),
- ),
- ),
- Expanded(
- child: Text(
- formatAmount(trade.quantity, decimals: qtyDecimals),
- textAlign: TextAlign.right,
- style: TextStyle(color: cs.onSurface, fontSize: 12),
- ),
- ),
- ],
- ),
- );
- }
- }
- // ── 底部买入/卖出 或 开多/开空 按钮 ─────────────────────────────────────
- class _BottomActions extends StatelessWidget {
- const _BottomActions({required this.marketKey});
- final MarketDetailKey marketKey;
- @override
- Widget build(BuildContext context) {
- final symbol = marketKey.symbol;
- final isFutures = marketKey.isFutures;
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final l10n = AppLocalizations.of(context)!;
- final buyLabel = isFutures ? l10n.openLong : l10n.buy;
- final sellLabel = isFutures ? l10n.openShort : l10n.sell;
- final route = isFutures ? '/futures/$symbol' : '/spot/$symbol';
- return Container(
- padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- border: Border(top: BorderSide(color: cs.outline)),
- ),
- child: Row(
- children: [
- Expanded(
- child: ElevatedButton(
- onPressed: () => context.go(route),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.rise,
- foregroundColor: Colors.white,
- minimumSize: const Size(0, 46),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- elevation: 0,
- ),
- child: Text(buyLabel,
- style: const TextStyle(
- fontSize: 16, fontWeight: FontWeight.w600)),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: ElevatedButton(
- onPressed: () => context.go(route),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.fall,
- foregroundColor: Colors.white,
- minimumSize: const Size(0, 46),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- elevation: 0,
- ),
- child: Text(sellLabel,
- style: const TextStyle(
- fontSize: 16, fontWeight: FontWeight.w600)),
- ),
- ),
- ],
- ),
- );
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 概览 Tab(币种信息 + 关键数据)
- // ══════════════════════════════════════════════════════════════
- class _InfoTab extends ConsumerWidget {
- const _InfoTab({required this.marketKey});
- final MarketDetailKey marketKey;
- String _formatPrice(double? v) {
- if (v == null) return '--';
- // 移除多余尾零,保留有效精度
- final s = v.toStringAsFixed(8).replaceAll(RegExp(r'\.?0+$'), '');
- return '\$$s';
- }
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final symbol = marketKey.symbol;
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final baseAsset =
- symbol.toUpperCase().replaceAll('USDT', '').replaceAll('PERP', '');
- final coinExt = ref.watch(
- marketDetailProvider(marketKey).select((s) => s.coinExt),
- );
- // 从 API 数据构建关键数据行(无数据时展示 --)
- List<(String, String, String?)> buildInfoRows() {
- final l10n = AppLocalizations.of(context)!;
- return [
- (l10n.rank, coinExt?.rank != null ? 'No.${coinExt!.rank}' : '--', null),
- (l10n.marketCap, coinExt?.marketCap ?? '--', null),
- (l10n.circulatingSupply, coinExt?.circulatingSupply ?? '--', null),
- (l10n.issuePrice, _formatPrice(coinExt?.issuePrice), null),
- (l10n.athPrice, _formatPrice(coinExt?.athPrice), coinExt?.athDate),
- ];
- }
- final info = buildInfoRows();
- final coinName = coinExt?.nameCn ?? baseAsset;
- final coinNameEn = coinExt?.nameEn ?? '';
- final iconUrl = coinExt?.icon;
- return SingleChildScrollView(
- physics: const ClampingScrollPhysics(),
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // ── 币种信息卡 ──────────────────────────────────
- Container(
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color: isDark
- ? AppColors.darkBgSecondary
- : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Row(
- children: [
- // 圆形图标(优先网络图片,失败降级为字母)
- CoinIcon(
- symbol: baseAsset,
- iconUrl: iconUrl ?? '',
- size: 40,
- shape: BoxShape.circle,
- ),
- const SizedBox(width: 12),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Text(
- baseAsset,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w700,
- ),
- ),
- if (coinName.isNotEmpty && coinName != baseAsset) ...[
- const SizedBox(width: 6),
- Text(
- '($coinName)',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 14,
- ),
- ),
- ],
- ],
- ),
- if (coinNameEn.isNotEmpty) ...[
- const SizedBox(height: 2),
- Text(
- coinNameEn,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 12,
- ),
- ),
- ],
- ],
- ),
- ],
- ),
- ),
- if (info.isNotEmpty) ...[
- const SizedBox(height: 20),
- // ── 关键数据 ──────────────────────────────────
- Text(
- AppLocalizations.of(context)!.keyData,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 8),
- Container(
- decoration: BoxDecoration(
- color: isDark
- ? AppColors.darkBgSecondary
- : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- children: [
- for (var i = 0; i < info.length; i++) ...[
- if (i > 0)
- Divider(
- color: cs.outline,
- height: 1,
- indent: 16,
- endIndent: 16,
- ),
- _InfoRow(
- label: info[i].$1,
- value: info[i].$2,
- subValue: info[i].$3,
- ),
- ],
- ],
- ),
- ),
- ],
- const SizedBox(height: 20),
- // ── 免责声明 ──────────────────────────────────
- Center(
- child: Text(
- AppLocalizations.of(context)!.dataDisclaimer,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11,
- ),
- ),
- ),
- const SizedBox(height: 16),
- ],
- ),
- );
- }
- }
- // ── 关键数据行 ──────────────────────────────────────────────
- class _InfoRow extends StatelessWidget {
- const _InfoRow({
- required this.label,
- required this.value,
- this.subValue,
- });
- final String label;
- final String value;
- final String? subValue;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- label,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 14,
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(
- value,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- ),
- if (subValue != null)
- Text(
- subValue!,
- style: TextStyle(
- color: cs.onSurface.withAlpha(150),
- fontSize: 11,
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 数据 Tab(资金费率图表 + 历史记录)
- // ══════════════════════════════════════════════════════════════
- class _DataTab extends ConsumerWidget {
- const _DataTab({required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final contractCoinId = ref.watch(
- futuresProvider(symbol).select((s) => s.contractCoinId),
- );
- if (contractCoinId <= 0) {
- return const Center(child: CircularProgressIndicator());
- }
- final state = ref.watch(fundingRateProvider(contractCoinId));
- final l10n = AppLocalizations.of(context)!;
- final cs = Theme.of(context).colorScheme;
- if (state.isLoading) {
- return const Center(child: CircularProgressIndicator());
- }
- if (state.error != null && state.history.isEmpty) {
- return Center(
- child: Text(l10n.loadFailed,
- style: TextStyle(color: cs.onSurface.withAlpha(153))),
- );
- }
- final current = state.current;
- final history = state.history;
- final ratePct = current != null
- ? '${current.rate >= 0 ? '+' : ''}${(current.rate * 100).toStringAsFixed(4)}%'
- : '--';
- // 下次结算倒计时(使用 futuresProvider 的 fundingCountdown,已由 WS 驱动)
- final countdown = ref.watch(
- futuresProvider(symbol).select((s) => s.fundingCountdown),
- );
- // 去掉 ScrollView 的横向 padding,改为对各区块单独加 padding,
- // 图表本身不加,实现全宽贴边显示
- const hPad = EdgeInsets.symmetric(horizontal: 16);
- return SingleChildScrollView(
- physics: const ClampingScrollPhysics(),
- padding: const EdgeInsets.only(top: 16, bottom: 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // ── 当前资金费率 ──────────────────────────────────
- Padding(
- padding: hPad,
- child: RichText(
- text: TextSpan(
- children: [
- TextSpan(
- text: '${l10n.fundingRate}:',
- style: TextStyle(
- color: cs.onSurface.withAlpha(180),
- fontSize: 14,
- ),
- ),
- TextSpan(
- text: ratePct,
- style: TextStyle(
- color: current != null && current.rate >= 0
- ? AppColors.rise
- : AppColors.fall,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 12),
- // ── 折线图(全宽,不加横向 padding)────────────────
- if (history.isNotEmpty)
- _FundingRateChart(history: history)
- else
- Container(
- height: 180,
- alignment: Alignment.center,
- child: Text(l10n.noData,
- style: TextStyle(color: cs.onSurface.withAlpha(120))),
- ),
- const SizedBox(height: 16),
- // ── 时间周期 & 倒计时 ────────────────────────────
- Padding(
- padding: hPad,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _infoRow(context, l10n.timePeriod, '8H'),
- Divider(color: cs.outline.withAlpha(80), height: 1),
- _infoRow(context, l10n.nextFundingCountdown, countdown),
- Divider(color: cs.outline, height: 1),
- const SizedBox(height: 12),
- // ── 历史表头 ──────────────────────────────────────
- _HistoryHeader(l10n: l10n),
- const SizedBox(height: 4),
- // ── 历史列表 ──────────────────────────────────────
- if (history.isEmpty)
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 24),
- child: Center(
- child: Text(l10n.noData,
- style: TextStyle(color: cs.onSurface.withAlpha(120))),
- ),
- )
- else
- ...history.reversed.map((item) => _HistoryRow(item: item)),
- ],
- ),
- ),
- ],
- ),
- );
- }
- Widget _infoRow(BuildContext context, String label, String value) {
- final cs = Theme.of(context).colorScheme;
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 12),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(label,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
- Text(value, style: TextStyle(color: cs.onSurface, fontSize: 14)),
- ],
- ),
- );
- }
- }
- // ── 资金费率折线图 ────────────────────────────────────────
- class _FundingRateChart extends StatelessWidget {
- const _FundingRateChart({required this.history});
- final List<FundingRateHistoryItem> history;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final items = history;
- if (items.isEmpty) return const SizedBox.shrink();
- final rates = items.map((e) => e.rate).toList();
- final minRate = rates.reduce((a, b) => a < b ? a : b);
- final maxRate = rates.reduce((a, b) => a > b ? a : b);
- final rangePad = (maxRate - minRate) * 0.25;
- final yMin = minRate - rangePad;
- final yMax = maxRate + rangePad;
- final spots = items.asMap().entries.map((e) {
- return FlSpot(e.key.toDouble(), e.value.rate);
- }).toList();
- // 与 K 线图保持一致的配色
- final bgColor = isDark ? AppColors.darkBg : AppColors.lightBg;
- final gridColor = cs.outline.withAlpha(50);
- final textColor =
- isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary;
- const lineColor = AppColors.chartLineBlue;
- final tooltipBg =
- isDark ? AppColors.darkBgSecondary : const Color(0xFF3A3A3C);
- // x 轴标签间距:最多显示 5 个
- final xInterval = (items.length / 5).floorToDouble().clamp(1.0, 999.0);
- return Container(
- height: 220,
- color: bgColor,
- padding: const EdgeInsets.only(top: 8, bottom: 2, left: 4),
- child: LineChart(
- LineChartData(
- minY: yMin,
- maxY: yMax,
- clipData: const FlClipData.all(),
- gridData: FlGridData(
- show: true,
- drawVerticalLine: true,
- verticalInterval: xInterval,
- horizontalInterval: (yMax - yMin) / 4,
- getDrawingHorizontalLine: (_) =>
- FlLine(color: gridColor, strokeWidth: 0.5),
- getDrawingVerticalLine: (_) =>
- FlLine(color: gridColor, strokeWidth: 0.5),
- ),
- borderData: FlBorderData(show: false),
- titlesData: FlTitlesData(
- leftTitles:
- const AxisTitles(sideTitles: SideTitles(showTitles: false)),
- topTitles:
- const AxisTitles(sideTitles: SideTitles(showTitles: false)),
- rightTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- reservedSize: 58,
- getTitlesWidget: (val, meta) {
- // 只在网格线位置显示,跳过首尾极值
- if (val == meta.min || val == meta.max) {
- return const SizedBox.shrink();
- }
- final pct = (val * 100).toStringAsFixed(3);
- return Padding(
- padding: const EdgeInsets.only(left: 4),
- child: Text('$pct%',
- style: TextStyle(fontSize: 9, color: textColor)),
- );
- },
- ),
- ),
- bottomTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- reservedSize: 18,
- interval: xInterval,
- getTitlesWidget: (val, _) {
- final idx = val.toInt();
- if (idx < 0 || idx >= items.length) {
- return const SizedBox.shrink();
- }
- final date = items[idx].fundingTime;
- return Text(
- DateFormat('MM/dd').format(date),
- style: TextStyle(fontSize: 9, color: textColor),
- );
- },
- ),
- ),
- ),
- // 零轴参考线(虚线),与 K 线图十字线风格对齐
- extraLinesData: ExtraLinesData(
- horizontalLines: [
- HorizontalLine(
- y: 0,
- color: cs.outline.withAlpha(160),
- strokeWidth: 0.8,
- dashArray: [4, 4],
- ),
- ],
- ),
- lineTouchData: LineTouchData(
- enabled: true,
- // 十字线:与 K 线图 vCrossWidth / hCrossWidth 对齐
- getTouchedSpotIndicator: (_, indices) => indices.map((_) {
- return TouchedSpotIndicatorData(
- FlLine(color: cs.onSurface.withAlpha(160), strokeWidth: 0.5),
- FlDotData(
- show: true,
- getDotPainter: (_, __, ___, ____) => FlDotCirclePainter(
- radius: 3.5,
- color: lineColor,
- strokeWidth: 1.5,
- strokeColor: Colors.white,
- ),
- ),
- );
- }).toList(),
- touchTooltipData: LineTouchTooltipData(
- getTooltipColor: (_) => tooltipBg,
- tooltipRoundedRadius: 4,
- tooltipPadding:
- const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
- getTooltipItems: (touchedSpots) {
- return touchedSpots.map((spot) {
- final idx = spot.spotIndex;
- if (idx < 0 || idx >= items.length) return null;
- final item = items[idx];
- final dateStr =
- DateFormat('MM/dd HH:mm').format(item.fundingTime);
- final rateStr =
- '${item.rate >= 0 ? '+' : ''}${(item.rate * 100).toStringAsFixed(4)}%';
- final rateColor =
- item.rate >= 0 ? AppColors.rise : AppColors.fall;
- return LineTooltipItem(
- '$dateStr\n',
- TextStyle(
- color: isDark
- ? AppColors.darkTextSecondary
- : Colors.white70,
- fontSize: 10),
- children: [
- TextSpan(
- text: rateStr,
- style: TextStyle(
- color: rateColor,
- fontSize: 11,
- fontWeight: FontWeight.w600),
- ),
- ],
- );
- }).toList();
- },
- ),
- touchCallback: (_, __) {},
- handleBuiltInTouches: true,
- ),
- lineBarsData: [
- LineChartBarData(
- spots: spots,
- isCurved: true,
- curveSmoothness: 0.25,
- color: lineColor,
- barWidth: 1.5,
- dotData: const FlDotData(show: false),
- // 线下渐变填充,与 K 线图折线模式风格一致
- belowBarData: BarAreaData(
- show: true,
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: [
- lineColor.withAlpha(60),
- lineColor.withAlpha(0),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
- // ── 历史表头 & 行 ─────────────────────────────────────────
- class _HistoryHeader extends StatelessWidget {
- const _HistoryHeader({required this.l10n});
- final AppLocalizations l10n;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final style = TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12);
- return Row(
- children: [
- Expanded(child: Text(l10n.timeLabel, style: style)),
- Expanded(
- child: Text(l10n.fundingRate,
- style: style, textAlign: TextAlign.right)),
- ],
- );
- }
- }
- class _HistoryRow extends StatelessWidget {
- const _HistoryRow({required this.item});
- final FundingRateHistoryItem item;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final rateStr =
- '${item.rate >= 0 ? '+' : ''}${(item.rate * 100).toStringAsFixed(4)}%';
- final rateColor = item.rate >= 0 ? AppColors.rise : AppColors.fall;
- final timeStr = DateFormat('yyyy-MM-dd HH:mm').format(item.fundingTime);
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 10),
- child: Row(
- children: [
- Expanded(
- child: Text(
- timeStr,
- style: TextStyle(color: cs.onSurface, fontSize: 12),
- ),
- ),
- Text(
- rateStr,
- style: TextStyle(color: rateColor, fontSize: 12),
- ),
- ],
- ),
- );
- }
- }
- // ── 行情详情骨架屏 ─────────────────────────────────────────
- class _MarketDetailShimmer extends StatelessWidget {
- const _MarketDetailShimmer();
- @override
- Widget build(BuildContext context) {
- return AppShimmer(
- child: SingleChildScrollView(
- physics: const NeverScrollableScrollPhysics(),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 行情/概览 Tab 占位
- Container(
- height: 44,
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Row(
- children: [
- shimmerBox(32, 16),
- const SizedBox(width: 24),
- shimmerBox(32, 16),
- ],
- ),
- ),
- // 价格头部
- Padding(
- padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(160, 32, radius: 6),
- const SizedBox(height: 8),
- Row(
- children: [
- shimmerBox(80, 12),
- const SizedBox(width: 8),
- shimmerBox(50, 12),
- const SizedBox(width: 8),
- shimmerBox(90, 12),
- ],
- ),
- const SizedBox(height: 12),
- Row(
- children: List.generate(
- 3,
- (col) => Expanded(
- child: Column(
- crossAxisAlignment: col == 2
- ? CrossAxisAlignment.end
- : col == 1
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.start,
- children: [
- shimmerBox(48, 10),
- const SizedBox(height: 4),
- shimmerBox(60, 12),
- const SizedBox(height: 8),
- shimmerBox(48, 10),
- const SizedBox(height: 4),
- shimmerBox(60, 12),
- ],
- ),
- )),
- ),
- ],
- ),
- ),
- const SizedBox(height: 4),
- // 周期 Tab 占位
- SizedBox(
- height: 36,
- child: ListView.separated(
- scrollDirection: Axis.horizontal,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- physics: const NeverScrollableScrollPhysics(),
- itemCount: 7,
- separatorBuilder: (_, __) => const SizedBox(width: 16),
- itemBuilder: (_, __) => Center(child: shimmerBox(28, 14)),
- ),
- ),
- const SizedBox(height: 6),
- // K 线图区域占位
- shimmerFill(340, radius: 0),
- // 指标栏
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- child: Row(
- children: List.generate(
- 4,
- (i) => Padding(
- padding: const EdgeInsets.only(right: 12),
- child: shimmerBox(28, 14),
- )),
- ),
- ),
- const SizedBox(height: 4),
- // 历史收益率行
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: List.generate(
- 6,
- (_) => Column(
- children: [
- shimmerBox(24, 10),
- const SizedBox(height: 4),
- shimmerBox(32, 12),
- ],
- )),
- ),
- ),
- const SizedBox(height: 8),
- // 订单簿 Tab 行
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(
- children: [
- shimmerBox(48, 14),
- const SizedBox(width: 24),
- shimmerBox(48, 14),
- const SizedBox(width: 24),
- shimmerBox(36, 14),
- ],
- ),
- ),
- // 订单簿表头
- Padding(
- padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
- child: Row(
- children: [
- Expanded(child: shimmerBox(24, 10)),
- Expanded(child: Center(child: shimmerBox(56, 10))),
- Expanded(
- child: Align(
- alignment: Alignment.centerRight,
- child: shimmerBox(24, 10))),
- ],
- ),
- ),
- // 订单簿数据行
- ...List.generate(
- 10,
- (_) => Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 12, vertical: 5),
- child: Row(
- children: [
- Expanded(child: shimmerBox(52, 12)),
- Expanded(child: Center(child: shimmerBox(80, 12))),
- Expanded(
- child: Align(
- alignment: Alignment.centerRight,
- child: shimmerBox(52, 12))),
- ],
- ),
- )),
- const SizedBox(height: 16),
- ],
- ),
- ),
- );
- }
- }
|