import 'dart:async'; import 'package:flutter/material.dart'; import 'package:k_chart_plus/chart_translations.dart'; import 'package:k_chart_plus/components/popup_info_view.dart'; import 'package:k_chart_plus/k_chart_plus.dart'; import 'renderer/base_dimension.dart'; enum MainState { MA, BOLL, SAR } enum SecondaryState { MACD, KDJ, RSI, WR, CCI } class TimeFormat { static const List YEAR_MONTH_DAY = [yyyy, '-', mm, '-', dd]; static const List YEAR_MONTH_DAY_WITH_HOUR = [ yyyy, '-', mm, '-', dd, ' ', HH, ':', nn ]; } class KChartWidget extends StatefulWidget { final List? datas; final Set mainStateLi; final bool volHidden; final Set secondaryStateLi; // final Function()? onSecondaryTap; final bool isLine; final bool isTapShowInfoDialog; //Whether to enable click to display detailed data final bool hideGrid; final bool showNowPrice; final bool showInfoDialog; final bool materialInfoDialog; // Material Style Information Popup final ChartTranslations chartTranslations; final List timeFormat; final double mBaseHeight; // It will be called when the screen scrolls to the end. // If true, it will be scrolled to the end of the right side of the screen. // If it is false, it will be scrolled to the end of the left side of the screen. final Function(bool)? onLoadMore; final int fixedLength; final List maDayList; final int flingTime; final double flingRatio; final Curve flingCurve; final Function(bool)? isOnDrag; final ChartColors chartColors; final ChartStyle chartStyle; final VerticalTextAlignment verticalTextAlignment; final bool isTrendLine; final double xFrontPadding; /// 外部调用 .value = !.value 即可触发隐藏详情 final ValueNotifier? dismissInfoNotifier; /// 当前价格字符串(来自 WS ticker),覆盖图表默认的 toStringAsFixed 显示 final String? nowPriceStr; KChartWidget( this.datas, this.chartStyle, this.chartColors, { required this.isTrendLine, this.xFrontPadding = 100, this.dismissInfoNotifier, this.nowPriceStr, this.mainStateLi = const {}, this.secondaryStateLi = const {}, // this.onSecondaryTap, this.volHidden = false, this.isLine = false, this.isTapShowInfoDialog = false, this.hideGrid = false, this.showNowPrice = true, this.showInfoDialog = true, this.materialInfoDialog = true, this.chartTranslations = const ChartTranslations(), this.timeFormat = TimeFormat.YEAR_MONTH_DAY, this.onLoadMore, this.fixedLength = 2, this.maDayList = const [5, 10, 20], this.flingTime = 600, this.flingRatio = 0.5, this.flingCurve = Curves.decelerate, this.isOnDrag, this.verticalTextAlignment = VerticalTextAlignment.left, this.mBaseHeight = 360, }); @override _KChartWidgetState createState() => _KChartWidgetState(); } class _KChartWidgetState extends State with TickerProviderStateMixin { final StreamController mInfoWindowStream = StreamController(); double mScaleX = 1.0, mScrollX = 0.0, mSelectX = 0.0; double mHeight = 0, mWidth = 0; AnimationController? _controller; Animation? aniX; //For TrendLine List lines = []; double? changeinXposition; double? changeinYposition; double mSelectY = 0.0; bool waitingForOtherPairofCords = false; bool enableCordRecord = false; double getMinScrollX() { return mScaleX; } double _lastScale = 1.0; bool isScale = false, isDrag = false, isLongPress = false, isOnTap = false; @override void initState() { super.initState(); widget.dismissInfoNotifier?.addListener(_onDismiss); } void _onDismiss() { if (isOnTap || isLongPress) { isOnTap = false; isLongPress = false; mInfoWindowStream.sink.add(null); notifyChanged(); } } @override void didChangeDependencies() { super.didChangeDependencies(); } @override void dispose() { widget.dismissInfoNotifier?.removeListener(_onDismiss); mInfoWindowStream.sink.close(); mInfoWindowStream.close(); _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.datas != null && widget.datas!.isEmpty) { mScrollX = mSelectX = 0.0; mScaleX = 1.0; } final BaseDimension baseDimension = BaseDimension( mBaseHeight: widget.mBaseHeight, volHidden: widget.volHidden, secondaryStateLi: widget.secondaryStateLi, mainStateLi: widget.mainStateLi, ); final _painter = ChartPainter( widget.chartStyle, widget.chartColors, baseDimension: baseDimension, lines: lines, //For TrendLine sink: mInfoWindowStream.sink, xFrontPadding: widget.xFrontPadding, isTrendLine: widget.isTrendLine, //For TrendLine selectY: mSelectY, //For TrendLine datas: widget.datas, scaleX: mScaleX, scrollX: mScrollX, selectX: mSelectX, isLongPass: isLongPress, isOnTap: isOnTap, isTapShowInfoDialog: widget.isTapShowInfoDialog, mainStateLi: widget.mainStateLi, volHidden: widget.volHidden, secondaryStateLi: widget.secondaryStateLi, isLine: widget.isLine, hideGrid: widget.hideGrid, showNowPrice: widget.showNowPrice, fixedLength: widget.fixedLength, maDayList: widget.maDayList, verticalTextAlignment: widget.verticalTextAlignment, nowPriceStr: widget.nowPriceStr, ); return LayoutBuilder( builder: (context, constraints) { mHeight = constraints.maxHeight; mWidth = constraints.maxWidth; return GestureDetector( onTapUp: (details) { // if (!widget.isTrendLine && widget.onSecondaryTap != null && _painter.isInSecondaryRect(details.localPosition)) { // widget.onSecondaryTap!(); // } if (!widget.isTrendLine && _painter.isInMainRect(details.localPosition)) { // 在主图区域内任意点击:始终切换到点击位置对应的蜡烛详情, // 不再二次点击隐藏(用户期望点哪里就显示哪里的详情) isOnTap = true; if (widget.isTapShowInfoDialog) { mSelectX = details.localPosition.dx; notifyChanged(); } } else if (isOnTap) { // 点击主图以外区域(副图/成交量等)→ 隐藏 isOnTap = false; mInfoWindowStream.sink.add(null); notifyChanged(); } if (widget.isTrendLine && !isLongPress && enableCordRecord) { enableCordRecord = false; Offset p1 = Offset(getTrendLineX(), mSelectY); if (!waitingForOtherPairofCords) { lines.add(TrendLine( p1, Offset(-1, -1), trendLineMax!, trendLineScale!)); } if (waitingForOtherPairofCords) { var a = lines.last; lines.removeLast(); lines.add(TrendLine(a.p1, p1, trendLineMax!, trendLineScale!)); waitingForOtherPairofCords = false; } else { waitingForOtherPairofCords = true; } notifyChanged(); } }, // 统一使用 onScale 处理单指拖拽和双指缩放。 // 在 onScaleUpdate 中动态检测 pointerCount 切换模式, // 不依赖 onScaleStart 的初始判断(第二个手指可能晚于 start 按下)。 onScaleStart: (details) { isOnTap = false; _stopAnimation(); _onDragChanged(true); }, onScaleUpdate: (details) { if (isLongPress) return; if (details.scale != 1.0) { // 双指缩放(scale != 1.0 表示有缩放变化,不管角度) isScale = true; mScaleX = (_lastScale * details.scale).clamp(0.5, 2.2); notifyChanged(); } else if (!isScale) { // 单指水平拖拽 mScrollX = (details.focalPointDelta.dx / mScaleX + mScrollX) .clamp(0.0, ChartPainter.maxScrollX) .toDouble(); // 滑到左端 90% 时提前触发加载更早数据 if (ChartPainter.maxScrollX > 0 && mScrollX >= ChartPainter.maxScrollX * 0.9 && widget.onLoadMore != null) { widget.onLoadMore!(false); } notifyChanged(); } }, onScaleEnd: (details) { if (isScale) { isScale = false; _lastScale = mScaleX; } _onDragChanged(false); if (!isScale) { var velocity = details.velocity.pixelsPerSecond.dx; _onFling(velocity); } }, onLongPressStart: (details) { isOnTap = false; isLongPress = true; if ((mSelectX != details.localPosition.dx || mSelectY != details.globalPosition.dy) && !widget.isTrendLine) { mSelectX = details.localPosition.dx; notifyChanged(); } //For TrendLine if (widget.isTrendLine && changeinXposition == null) { mSelectX = changeinXposition = details.localPosition.dx; mSelectY = changeinYposition = details.globalPosition.dy; notifyChanged(); } //For TrendLine if (widget.isTrendLine && changeinXposition != null) { changeinXposition = details.localPosition.dx; changeinYposition = details.globalPosition.dy; notifyChanged(); } }, onLongPressMoveUpdate: (details) { if ((mSelectX != details.localPosition.dx || mSelectY != details.globalPosition.dy) && !widget.isTrendLine) { mSelectX = details.localPosition.dx; mSelectY = details.localPosition.dy; notifyChanged(); } if (widget.isTrendLine) { mSelectX = mSelectX + (details.localPosition.dx - changeinXposition!); changeinXposition = details.localPosition.dx; mSelectY = mSelectY + (details.globalPosition.dy - changeinYposition!); changeinYposition = details.globalPosition.dy; notifyChanged(); } }, onLongPressEnd: (details) { isLongPress = false; enableCordRecord = true; mInfoWindowStream.sink.add(null); notifyChanged(); }, child: Stack( children: [ CustomPaint( size: Size(double.infinity, baseDimension.mDisplayHeight), painter: _painter, ), if (widget.showInfoDialog) _buildInfoDialog() ], ), ); }, ); } void _stopAnimation({bool needNotify = true}) { if (_controller != null && _controller!.isAnimating) { _controller!.stop(); _onDragChanged(false); if (needNotify) { notifyChanged(); } } } void _onDragChanged(bool isOnDrag) { isDrag = isOnDrag; if (widget.isOnDrag != null) { widget.isOnDrag!(isDrag); } } void _onFling(double x) { _controller = AnimationController( duration: Duration(milliseconds: widget.flingTime), vsync: this); aniX = null; aniX = Tween(begin: mScrollX, end: x * widget.flingRatio + mScrollX) .animate(CurvedAnimation( parent: _controller!.view, curve: widget.flingCurve)); aniX!.addListener(() { mScrollX = aniX!.value; if (mScrollX <= 0) { mScrollX = 0; if (widget.onLoadMore != null) { widget.onLoadMore!(true); } _stopAnimation(); } else if (mScrollX >= ChartPainter.maxScrollX) { mScrollX = ChartPainter.maxScrollX; if (widget.onLoadMore != null) { widget.onLoadMore!(false); } _stopAnimation(); } notifyChanged(); }); aniX!.addStatusListener((status) { if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { _onDragChanged(false); notifyChanged(); } }); _controller!.forward(); } void notifyChanged() => setState(() {}); late List infos; Widget _buildInfoDialog() { return StreamBuilder( stream: mInfoWindowStream.stream, builder: (context, snapshot) { if ((!isLongPress && !isOnTap) || widget.isLine == true || !snapshot.hasData || snapshot.data?.kLineEntity == null) return SizedBox(); KLineEntity entity = snapshot.data!.kLineEntity; final dialogWidth = mWidth / 3; if (snapshot.data!.isLeft) { return Positioned( top: 25, left: 10.0, child: PopupInfoView( entity: entity, width: dialogWidth, chartColors: widget.chartColors, chartTranslations: widget.chartTranslations, materialInfoDialog: widget.materialInfoDialog, timeFormat: widget.timeFormat, fixedLength: widget.fixedLength, ), ); } return Positioned( top: 25, right: 10.0, child: PopupInfoView( entity: entity, width: dialogWidth, chartColors: widget.chartColors, chartTranslations: widget.chartTranslations, materialInfoDialog: widget.materialInfoDialog, timeFormat: widget.timeFormat, fixedLength: widget.fixedLength, ), ); }, ); } }