diff --git a/lib/src/deriv_chart/chart/bottom_chart_mobile.dart b/lib/src/deriv_chart/chart/bottom_chart_with_label.dart similarity index 92% rename from lib/src/deriv_chart/chart/bottom_chart_mobile.dart rename to lib/src/deriv_chart/chart/bottom_chart_with_label.dart index 5be27a0ad..db9ca8bd1 100644 --- a/lib/src/deriv_chart/chart/bottom_chart_mobile.dart +++ b/lib/src/deriv_chart/chart/bottom_chart_with_label.dart @@ -5,7 +5,6 @@ import 'package:deriv_chart/src/models/chart_config.dart'; import 'package:deriv_chart/src/theme/chart_theme.dart'; import 'package:deriv_chart/src/theme/colors.dart'; import 'package:deriv_chart/src/theme/dimens.dart'; -import 'package:deriv_chart/src/theme/text_styles.dart'; import 'package:deriv_chart/src/widgets/bottom_indicator_title.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -15,10 +14,12 @@ import 'data_visualization/chart_series/series.dart'; import 'indicator_label_icons.dart'; import 'x_axis/x_axis_model.dart'; -/// Mobile version of the chart to add the bottom indicators too. -class BottomChartMobile extends BasicChart { - /// Initializes a bottom chart mobile. - const BottomChartMobile({ +/// A bottom indicator's panel, rendered together with its [IndicatorLabel]. +/// +/// Used by both platforms - the label and its actions are identical on each. +class BottomChartWithLabel extends BasicChart { + /// Initializes a bottom indicator panel with its label. + const BottomChartWithLabel({ required Series series, required this.granularity, required this.title, @@ -85,10 +86,10 @@ class BottomChartMobile extends BasicChart { final IndicatorLabelIcons icons; @override - _BottomChartMobileState createState() => _BottomChartMobileState(); + _BottomChartWithLabelState createState() => _BottomChartWithLabelState(); } -class _BottomChartMobileState extends BasicChartState { +class _BottomChartWithLabelState extends BasicChartState { ChartTheme get theme => context.read(); @override @@ -113,7 +114,7 @@ class _BottomChartMobileState extends BasicChartState { Positioned( top: 4, left: widget.bottomChartTitleMargin?.left ?? 10, - child: _buildIndicatorLabelMobile(), + child: _buildIndicatorLabel(), ) ], ), @@ -131,7 +132,7 @@ class _BottomChartMobileState extends BasicChartState { ), ); - Widget _buildIndicatorLabelMobile() => IndicatorLabelMobile( + Widget _buildIndicatorLabel() => IndicatorLabel( title: widget.title, isExpanded: widget.isExpanded, showMoveUpIcon: widget.showMoveUpIcon, @@ -151,12 +152,12 @@ class _BottomChartMobileState extends BasicChartState { padding: EdgeInsets.only( left: widget.bottomChartTitleMargin?.left ?? 10, ), - child: _buildIndicatorLabelMobile(), + child: _buildIndicatorLabel(), ), ); @override - void didUpdateWidget(BottomChartMobile oldChart) { + void didUpdateWidget(BottomChartWithLabel oldChart) { super.didUpdateWidget(oldChart); xAxis.update( @@ -179,9 +180,9 @@ class _BottomChartMobileState extends BasicChartState { /// Expanding/collapsing only affects which action buttons are shown; it never /// hides the indicator's data - that is controlled independently by the eye /// (hide/unhide) button. -class IndicatorLabelMobile extends StatelessWidget { +class IndicatorLabel extends StatelessWidget { /// Initializes a bottom chart indicator label. - const IndicatorLabelMobile({ + const IndicatorLabel({ required this.title, required this.isExpanded, required this.showMoveUpIcon, @@ -256,13 +257,7 @@ class IndicatorLabelMobile extends StatelessWidget { behavior: HitTestBehavior.opaque, child: BottomIndicatorTitle( title, - theme.textStyle( - color: theme.base01Color, - textStyle: theme.textStyle( - textStyle: TextStyles.caption, - color: theme.base01Color, - ), - ), + theme.indicatorLabelTextStyle, ), ), // The action buttons slide in/out horizontally as the label is @@ -346,7 +341,7 @@ class IndicatorLabelMobile extends StatelessWidget { turns: isExpanded ? 0.5 : 0.0, child: Icon( icons.expandCollapse, - size: 16, + size: context.read().indicatorLabelIconSize, color: context.read().base01Color, ), ), @@ -373,7 +368,7 @@ class IndicatorLabelMobile extends StatelessWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap), icon: Icon( iconData, - size: 16, + size: context.read().indicatorLabelIconSize, color: context.read().base01Color, ), onPressed: onPressed, diff --git a/lib/src/deriv_chart/chart/chart.dart b/lib/src/deriv_chart/chart/chart.dart index fb5bb4a0b..a26fdfaf5 100644 --- a/lib/src/deriv_chart/chart/chart.dart +++ b/lib/src/deriv_chart/chart/chart.dart @@ -26,8 +26,7 @@ import '../../models/tick.dart'; import '../../theme/chart_default_dark_theme.dart'; import '../../theme/chart_theme.dart'; import '../interactive_layer/interactive_layer_behaviours/interactive_layer_behaviour.dart'; -import 'bottom_chart.dart'; -import 'bottom_chart_mobile.dart'; +import 'bottom_chart_with_label.dart'; import 'indicator_label_icons.dart'; import 'data_visualization/annotations/chart_annotation.dart'; import 'data_visualization/chart_data.dart'; @@ -332,8 +331,7 @@ class Chart extends StatefulWidget { /// Icons used by the on-chart indicator labels (eye, reorder arrows, /// settings, delete and the expand/collapse chevron). /// - /// Any icon left unset falls back to its Material default. Currently applied - /// on mobile. + /// Any icon left unset falls back to its Material default. final IndicatorLabelIcons? indicatorLabelIcons; @override @@ -348,7 +346,14 @@ abstract class _ChartState extends State with WidgetsBindingObserver { late ChartController _controller; late ChartTheme _chartTheme; late List? bottomSeries; - int? expandedIndex; + + /// Panel keys (see [_panelKeyFor]) of indicator labels currently expanded to + /// show their action buttons. Keyed by panel key - rather than held as local + /// widget state - so an indicator's expanded/collapsed state follows it + /// across reorders, hides and the frequent live-tick rebuilds, and never + /// gets attached to the wrong indicator. Labels default to collapsed (absent + /// from this set). + final Set _expandedLabelKeys = {}; /// Current fraction of the available height occupied by each chart panel, /// keyed by [PanelSizeRepository.mainPanelKey] for the main chart and by @@ -495,6 +500,90 @@ abstract class _ChartState extends State with WidgetsBindingObserver { (totalHeight - dividerCount * Dimens.chartPanelDividerHitHeight) .clamp(0.0, double.infinity); + /// Index of [element] within [list] by identity rather than equality - + /// indicator configs of the same type with the same settings compare equal, + /// so `indexOf` would find the wrong one. + int referenceIndexOf(List list, dynamic element) { + for (int i = 0; i < list.length; i++) { + if (identical(list[i], element)) { + return i; + } + } + return -1; + } + + /// Whether [config]'s label is currently showing its action buttons. + bool _isLabelExpanded(IndicatorConfig config) => + _expandedLabelKeys.contains(_panelKeyFor(config)); + + void _toggleLabelExpanded(IndicatorConfig config) { + final String key = _panelKeyFor(config); + setState(() { + if (!_expandedLabelKeys.remove(key)) { + _expandedLabelKeys.add(key); + } + }); + } + + /// The indicator-label icons supplied by the host app, or Material defaults. + IndicatorLabelIcons get _labelIcons => + widget.indicatorLabelIcons ?? const IndicatorLabelIcons(); + + void _onIndicatorHideToggleTapped( + Repository? repository, + int index, + ) { + repository?.updateHiddenStatus( + index: index, + hidden: !repository.getHiddenStatus(index), + ); + } + + /// The title shown on an indicator's label - its short name, the instance + /// number once there is more than one of a type, and its settings summary. + String _indicatorLabelTitle(IndicatorConfig config) => + '${config.shortTitle} ${config.number > 0 ? config.number : ''}' + '${config.configSummary.isEmpty ? '' : ' (${config.configSummary})'}'; + + /// Labels for the overlay indicators drawn on the main chart, stacked at its + /// top-left. Bottom indicators carry their own label inside their panel. + Widget _buildOverlayIndicatorsLabels() { + final List overlayIndicatorsLabels = []; + if (widget.indicatorsRepo != null) { + for (int i = 0; i < widget.indicatorsRepo!.items.length; i++) { + final IndicatorConfig config = widget.indicatorsRepo!.items[i]; + if (!config.isOverlay) { + continue; + } + + overlayIndicatorsLabels.add( + Padding( + padding: const EdgeInsets.only(bottom: Dimens.margin04), + child: IndicatorLabel( + title: _indicatorLabelTitle(config), + isExpanded: _isLabelExpanded(config), + showMoveUpIcon: false, + showMoveDownIcon: false, + isHidden: widget.indicatorsRepo?.getHiddenStatus(i) ?? false, + icons: _labelIcons, + onExpandToggle: () => _toggleLabelExpanded(config), + onHideUnhideToggle: () { + _onIndicatorHideToggleTapped(widget.indicatorsRepo, i); + }, + onEdit: () => _onEdit(config), + onRemove: () => _onRemove(config), + ), + ), + ); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: overlayIndicatorsLabels, + ); + } + void _onCrosshairHover( Offset globalPosition, Offset localPosition, @@ -599,8 +688,6 @@ abstract class _ChartState extends State with WidgetsBindingObserver { } void _onRemove(IndicatorConfig config) { - expandedIndex = null; - if (widget.indicatorsRepo != null) { final int index = widget.indicatorsRepo!.items.indexOf(config); widget.indicatorsRepo!.removeAt(index); @@ -681,16 +768,5 @@ abstract class _ChartState extends State with WidgetsBindingObserver { _controller.onScrollToLastTick?.call(animate: false); } } - - // Check if the the expanded bottom indicator is moved/removed. - if (expandedIndex != null && - oldWidget.bottomConfigs.length != widget.bottomConfigs.length && - expandedIndex! < (oldWidget.bottomConfigs.length)) { - final int? newIndex = - widget.bottomConfigs.indexOf(oldWidget.bottomConfigs[expandedIndex!]); - if (newIndex != expandedIndex) { - expandedIndex = newIndex == -1 ? null : newIndex; - } - } } } diff --git a/lib/src/deriv_chart/chart/chart_state_mobile.dart b/lib/src/deriv_chart/chart/chart_state_mobile.dart index 52e85cb44..cc59ea8af 100644 --- a/lib/src/deriv_chart/chart/chart_state_mobile.dart +++ b/lib/src/deriv_chart/chart/chart_state_mobile.dart @@ -1,30 +1,6 @@ part of 'chart.dart'; class _ChartStateMobile extends _ChartState { - /// Panel keys (see [_panelKeyFor]) of indicator labels currently expanded to - /// show their action buttons. Keyed by panel key - rather than held as local - /// widget state - so an indicator's expanded/collapsed state follows it - /// across reorders, hides and the frequent live-tick rebuilds, and never - /// gets attached to the wrong indicator. Labels default to collapsed (absent - /// from this set). - final Set _expandedLabelKeys = {}; - - bool _isLabelExpanded(IndicatorConfig config) => - _expandedLabelKeys.contains(_panelKeyFor(config)); - - void _toggleLabelExpanded(IndicatorConfig config) { - final String key = _panelKeyFor(config); - setState(() { - if (!_expandedLabelKeys.remove(key)) { - _expandedLabelKeys.add(key); - } - }); - } - - /// The indicator-label icons supplied by the host app, or Material defaults. - IndicatorLabelIcons get _labelIcons => - widget.indicatorLabelIcons ?? const IndicatorLabelIcons(); - @override Widget buildChartsLayout( BuildContext context, @@ -107,15 +83,13 @@ class _ChartStateMobile extends _ChartState { final int indexInBottomConfigs = referenceIndexOf(widget.bottomConfigs, config); - final Widget bottomChart = BottomChartMobile( + final Widget bottomChart = BottomChartWithLabel( series: series, isHidden: isHidden, isExpanded: _isLabelExpanded(config), granularity: widget.granularity, pipSize: config.pipSize, - title: - '${config.shortTitle} ${config.number > 0 ? config.number : ''}' - '${config.configSummary.isEmpty ? '' : ' (${config.configSummary})'}', + title: _indicatorLabelTitle(config), currentTickAnimationDuration: currentTickAnimationDuration, quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, bottomChartTitleMargin: const EdgeInsets.only(left: Dimens.margin04), @@ -286,64 +260,6 @@ class _ChartStateMobile extends _ChartState { ), ); - int referenceIndexOf(List list, dynamic element) { - for (int i = 0; i < list.length; i++) { - if (identical(list[i], element)) { - return i; - } - } - return -1; - } - - void _onIndicatorHideToggleTapped( - Repository? repository, - int index, - ) { - repository?.updateHiddenStatus( - index: index, - hidden: !repository.getHiddenStatus(index), - ); - } - double _getBottomIndicatorsSectionHeightFraction(int bottomIndicatorsCount) => 1 - (0.65 - 0.125 * (bottomIndicatorsCount - 1)); - - Widget _buildOverlayIndicatorsLabels() { - final List overlayIndicatorsLabels = []; - if (widget.indicatorsRepo != null) { - for (int i = 0; i < widget.indicatorsRepo!.items.length; i++) { - final IndicatorConfig config = widget.indicatorsRepo!.items[i]; - if (!config.isOverlay) { - continue; - } - - overlayIndicatorsLabels.add( - Padding( - padding: const EdgeInsets.only(bottom: Dimens.margin04), - child: IndicatorLabelMobile( - title: - '${config.shortTitle} ${config.number > 0 ? config.number : ''}' - '${config.configSummary.isEmpty ? '' : ' (${config.configSummary})'}', - isExpanded: _isLabelExpanded(config), - showMoveUpIcon: false, - showMoveDownIcon: false, - isHidden: widget.indicatorsRepo?.getHiddenStatus(i) ?? false, - icons: _labelIcons, - onExpandToggle: () => _toggleLabelExpanded(config), - onHideUnhideToggle: () { - _onIndicatorHideToggleTapped(widget.indicatorsRepo, i); - }, - onEdit: () => _onEdit(config), - onRemove: () => _onRemove(config), - ), - ), - ); - } - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: overlayIndicatorsLabels, - ); - } } diff --git a/lib/src/deriv_chart/chart/chart_state_web.dart b/lib/src/deriv_chart/chart/chart_state_web.dart index 14be0fe31..576efc2b0 100644 --- a/lib/src/deriv_chart/chart/chart_state_web.dart +++ b/lib/src/deriv_chart/chart/chart_state_web.dart @@ -1,9 +1,6 @@ part of 'chart.dart'; class _ChartStateWeb extends _ChartState { - String _bottomPanelKey(int index) => - _panelKeyFor(widget.bottomConfigs[index]); - @override Widget buildChartsLayout( BuildContext context, @@ -16,154 +13,207 @@ class _ChartStateWeb extends _ChartState { final Duration quoteBoundsAnimationDuration = widget.quoteBoundsAnimationDuration ?? _defaultDuration; - final bool isExpanded = expandedIndex != null; - final int totalBottomCount = widget.bottomConfigs.length; + final Repository? repository = widget.indicatorsRepo; + + // The repository is the source of truth whenever the host supplies one - + // it is what carries hidden status. `Chart` also accepts plain + // `overlayConfigs`/`bottomConfigs` with no repository at all, and those + // hosts still get their indicators drawn; they simply have nowhere to + // record a hidden state, so nothing is hidden. + final List bottomPanelConfigs = repository != null + ? [ + for (final IndicatorConfig config in repository.items) + if (!config.isOverlay) config + ] + : widget.bottomConfigs; + + // Each panel's true index within `repository.items`, for hidden-status + // lookups. Empty when there is no repository. + final List bottomRepoIndices = [ + if (repository != null) + for (int i = 0; i < repository.items.length; i++) + if (!repository.items[i].isOverlay) i + ]; - // Fractions are tracked for every bottom panel regardless of whether - // it's currently visible, so expanding/collapsing one to fullscreen - // doesn't discard the custom sizes of the others. - final List allPanelKeys = [ + // Every bottom indicator's key, visible or hidden, in order. Keeping + // hidden ones in this list is what preserves their stored fraction while + // hidden (see [syncPanelFractions]), so unhiding restores the exact size + // the panel had before. + final List allBottomIndicatorKeys = + bottomPanelConfigs.map(_panelKeyFor).toList(); + + // One flat, ordered chain covering the main chart and every bottom panel, + // so a resize can cascade past a panel already at its minimum height into + // the next one that still has room. + final List orderedKeys = [ PanelSizeRepository.mainPanelKey, - for (int i = 0; i < totalBottomCount; i++) _bottomPanelKey(i), + ...allBottomIndicatorKeys, ]; + final int totalBottomCount = allBottomIndicatorKeys.length; + _syncPanelFractions( - allPanelKeys, + orderedKeys, (String key) => key == PanelSizeRepository.mainPanelKey ? (totalBottomCount > 0 ? 3 / (3 + totalBottomCount) : 1.0) : 1 / (3 + totalBottomCount), ); - final double mainFraction = - _panelFractions[PanelSizeRepository.mainPanelKey] ?? 1.0; - - // While a single bottom panel is expanded to fullscreen, it takes up - // the entire bottom region (instead of its own stored fraction), and - // the main chart keeps the fraction it had before expanding. - double fractionFor(String key) => - isExpanded ? (1 - mainFraction) : (_panelFractions[key] ?? 0); + // Overlay indicators are drawn on the main chart, so a hidden one is + // simply left out of the series list; its label stays (see + // [_buildOverlayIndicatorsLabels]) so it can be unhidden again. + // + // Without a repository the series already built from `overlayConfigs` are + // used as they are. The two sources are deliberately never merged: + // `DerivChart` derives `overlayConfigs` *from* the repository it also + // passes, so combining them would draw every overlay twice. + final List visibleOverlaySeries = []; + if (repository != null) { + for (int i = 0; i < repository.items.length; i++) { + final IndicatorConfig config = repository.items[i]; + if (repository.getHiddenStatus(i) || !config.isOverlay) { + continue; + } + visibleOverlaySeries.add(config.getSeries( + IndicatorInput(widget.mainSeries.input, widget.granularity), + )); + } + } else { + visibleOverlaySeries.addAll(overlaySeries ?? const []); + } return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - final int dividerCount = isExpanded ? 0 : totalBottomCount; + // Each divider takes up real space in the same Column as the panels, + // so it has to come off the height the fractions are applied to. final double usableHeight = - _usableHeightFor(constraints.maxHeight, dividerCount); - - Widget panelSizedBox(double fraction, Widget child) => SizedBox( - height: fraction * usableHeight, - child: child, - ); + _usableHeightFor(constraints.maxHeight, totalBottomCount); final List children = [ - panelSizedBox( - mainFraction, - MainChart( - drawingTools: widget.drawingTools, - controller: _controller, - mainSeries: widget.mainSeries, - overlaySeries: overlaySeries, - annotations: widget.annotations, - markerSeries: widget.markerSeries, - pipSize: widget.pipSize, - onCrosshairAppeared: widget.onCrosshairAppeared, - onQuoteAreaChanged: widget.onQuoteAreaChanged, - isLive: widget.isLive, - showLoadingAnimationForHistoricalData: !widget.dataFitEnabled, - showDataFitButton: - widget.showDataFitButton ?? widget.dataFitEnabled, - showScrollToLastTickButton: - widget.showScrollToLastTickButton ?? true, - opacity: widget.opacity, - chartAxisConfig: widget.chartAxisConfig, - verticalPaddingFraction: widget.verticalPaddingFraction, - showCrosshair: widget.showCrosshair, - onCrosshairDisappeared: widget.onCrosshairDisappeared, - onCrosshairHover: _onCrosshairHover, - loadingAnimationColor: widget.loadingAnimationColor, - currentTickAnimationDuration: currentTickAnimationDuration, - quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, - showCurrentTickBlinkAnimation: - widget.showCurrentTickBlinkAnimation ?? true, - crosshairVariant: widget.crosshairVariant, - interactiveLayerBehaviour: widget.interactiveLayerBehaviour, - useDrawingToolsV2: widget.useDrawingToolsV2, + SizedBox( + height: (_panelFractions[PanelSizeRepository.mainPanelKey] ?? 1.0) * + usableHeight, + child: Stack( + children: [ + MainChart( + drawingTools: widget.drawingTools, + controller: _controller, + mainSeries: widget.mainSeries, + overlaySeries: visibleOverlaySeries, + annotations: widget.annotations, + markerSeries: widget.markerSeries, + pipSize: widget.pipSize, + onCrosshairAppeared: widget.onCrosshairAppeared, + onQuoteAreaChanged: widget.onQuoteAreaChanged, + isLive: widget.isLive, + showLoadingAnimationForHistoricalData: !widget.dataFitEnabled, + showDataFitButton: + widget.showDataFitButton ?? widget.dataFitEnabled, + showScrollToLastTickButton: + widget.showScrollToLastTickButton ?? true, + opacity: widget.opacity, + chartAxisConfig: widget.chartAxisConfig, + verticalPaddingFraction: widget.verticalPaddingFraction, + showCrosshair: widget.showCrosshair, + onCrosshairDisappeared: widget.onCrosshairDisappeared, + onCrosshairHover: _onCrosshairHover, + loadingAnimationColor: widget.loadingAnimationColor, + currentTickAnimationDuration: currentTickAnimationDuration, + quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, + showCurrentTickBlinkAnimation: + widget.showCurrentTickBlinkAnimation ?? true, + crosshairVariant: widget.crosshairVariant, + interactiveLayerBehaviour: widget.interactiveLayerBehaviour, + useDrawingToolsV2: widget.useDrawingToolsV2, + ), + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Dimens.margin08, + horizontal: Dimens.margin04, + ), + child: _buildOverlayIndicatorsLabels(), + ), + ), + ], ), ), ]; - for (int index = 0; index < totalBottomCount; index++) { - if (isExpanded && expandedIndex != index) { - continue; - } - - final String key = _bottomPanelKey(index); + for (int position = 0; + position < bottomPanelConfigs.length; + position++) { + final IndicatorConfig config = bottomPanelConfigs[position]; + final int repoIndex = + repository != null ? bottomRepoIndices[position] : -1; + final bool isHidden = + repository != null && repository.getHiddenStatus(repoIndex); + final String key = _panelKeyFor(config); + + final Series series = config.getSeries( + IndicatorInput(widget.mainSeries.input, widget.granularity), + ); - // Dragging is only meaningful between panels that are both - // visible with independently-tracked fractions; disable it while - // a single panel is expanded to fullscreen. - if (!isExpanded) { - final int dividerIndex = index; - children.add( + // TODO(Ramin): Use the key (type + number) once it's implemented. + final int indexInBottomConfigs = + referenceIndexOf(widget.bottomConfigs, config); + + // The divider directly above this panel sits between + // `orderedKeys[position]` (main, or the previous indicator) and + // `orderedKeys[position + 1]` (this one). + final int dividerIndex = position; + + // A panel's label is fixed-size text and icons rather than freely + // scalable chart content, so its fraction can work out to less pixel + // height than the label needs. Flooring only the rendered height + // keeps the label from being clipped without affecting the size a + // hidden panel is restored to on unhide. + final double panelHeight = (_panelFractions[key] ?? 0) * usableHeight; + final double renderedHeight = + math.max(panelHeight, Dimens.indicatorTitleBarMinHeight); + + children + ..add( ResizableChartDivider( onDragUpdate: (double deltaPixels) => _resizeCascadingPanels( - allPanelKeys, + orderedKeys, dividerIndex, deltaPixels / usableHeight, + usableHeight: usableHeight, ), onDragEnd: _persistPanelFractions, ), - ); - } - - children.add( - panelSizedBox( - fractionFor(key), - BottomChart( - series: bottomSeries![index], - granularity: widget.granularity, - pipSize: widget.bottomConfigs[index].pipSize, - title: widget.bottomConfigs[index].title, - currentTickAnimationDuration: currentTickAnimationDuration, - quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, - bottomChartTitleMargin: widget.bottomChartTitleMargin, - onRemove: () => _onRemove(widget.bottomConfigs[index]), - onEdit: () => _onEdit(widget.bottomConfigs[index]), - onExpandToggle: () { - setState(() { - expandedIndex = expandedIndex != index ? index : null; - }); - }, - onSwap: (int offset) => _onSwap(widget.bottomConfigs[index], - widget.bottomConfigs[index + offset]), - onCrosshairDisappeared: widget.onCrosshairDisappeared, - onCrosshairHover: ( - Offset globalPosition, - Offset localPosition, - EpochToX epochToX, - QuoteToY quoteToY, - EpochFromX epochFromX, - QuoteFromY quoteFromY, - ) => - widget.onCrosshairHover?.call( - globalPosition, - localPosition, - epochToX, - quoteToY, - epochFromX, - quoteFromY, - widget.bottomConfigs[index], + ) + ..add( + SizedBox( + height: renderedHeight, + child: BottomChartWithLabel( + series: series, + isHidden: isHidden, + isExpanded: _isLabelExpanded(config), + granularity: widget.granularity, + pipSize: config.pipSize, + title: _indicatorLabelTitle(config), + currentTickAnimationDuration: currentTickAnimationDuration, + quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, + bottomChartTitleMargin: widget.bottomChartTitleMargin, + icons: _labelIcons, + onExpandToggle: () => _toggleLabelExpanded(config), + onHideUnhideToggle: () => + _onIndicatorHideToggleTapped(repository, repoIndex), + onEdit: () => _onEdit(config), + onRemove: () => _onRemove(config), + onSwap: (int offset) => _onSwap(config, + widget.bottomConfigs[indexInBottomConfigs + offset]), + showMoveUpIcon: + totalBottomCount > 1 && indexInBottomConfigs != 0, + showMoveDownIcon: totalBottomCount > 1 && + indexInBottomConfigs != totalBottomCount - 1, + showFrame: false, ), - isExpanded: isExpanded, - showCrosshair: widget.showCrosshair, - showExpandedIcon: totalBottomCount > 1, - showMoveUpIcon: - !isExpanded && totalBottomCount > 1 && index != 0, - showMoveDownIcon: !isExpanded && - totalBottomCount > 1 && - index != totalBottomCount - 1, ), - ), - ); + ); } return Column(children: children); diff --git a/lib/src/deriv_chart/chart/indicator_label_icons.dart b/lib/src/deriv_chart/chart/indicator_label_icons.dart index 0b15083bf..500d6509e 100644 --- a/lib/src/deriv_chart/chart/indicator_label_icons.dart +++ b/lib/src/deriv_chart/chart/indicator_label_icons.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; /// The set of icons used by the on-chart indicator label -/// (`IndicatorLabelMobile`) - the eye (hide/unhide), reorder arrows, settings +/// (`IndicatorLabel`) - the eye (hide/unhide), reorder arrows, settings /// (gear), delete (trash) and expand/collapse chevron. /// /// Every icon is an [IconData] and defaults to a Material icon, so the library diff --git a/lib/src/deriv_chart/deriv_chart_lite.dart b/lib/src/deriv_chart/deriv_chart_lite.dart index a8d26e84f..2537d5fd9 100644 --- a/lib/src/deriv_chart/deriv_chart_lite.dart +++ b/lib/src/deriv_chart/deriv_chart_lite.dart @@ -8,6 +8,8 @@ import 'package:deriv_chart/src/deriv_chart/chart/data_visualization/annotations import 'package:deriv_chart/src/deriv_chart/chart/data_visualization/chart_series/data_series.dart'; import 'package:deriv_chart/src/deriv_chart/chart/data_visualization/markers/marker_series.dart'; import 'package:deriv_chart/src/deriv_chart/chart/data_visualization/models/chart_object.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/indicator_label_icons.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/panel_size/panel_size_repository.dart'; import 'package:deriv_chart/src/deriv_chart/drawing_tool_chart/drawing_tools.dart'; import 'package:deriv_chart/src/deriv_chart/interactive_layer/interactive_layer_controller.dart'; import 'package:deriv_chart/src/deriv_chart/interactive_layer/crosshair/crosshair_variant.dart'; @@ -19,14 +21,19 @@ import 'package:deriv_chart/src/theme/chart_theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'interactive_layer/interactive_layer_behaviours/interactive_layer_behaviour.dart'; import 'interactive_layer/interactive_layer_behaviours/interactive_layer_desktop_behaviour.dart'; import 'interactive_layer/interactive_layer_behaviours/interactive_layer_mobile_behaviour.dart'; -/// A lite version of [DerivChart] that does not import dialog or -/// shared_preferences dependencies, keeping the web bundle smaller for -/// consumers that manage their own [indicatorsRepo] and [drawingToolsRepo]. +/// A lite version of [DerivChart] that does not import dialog dependencies, +/// keeping the web bundle smaller for consumers that manage their own +/// [indicatorsRepo] and [drawingToolsRepo]. +/// +/// Panel-size persistence is the one exception: leaving [panelSizeRepo] unset +/// makes this widget create its own repository backed by `shared_preferences`. +/// Supply a [panelSizeRepo] to control that yourself. class DerivChart extends StatefulWidget { /// Initializes const DerivChart({ @@ -66,12 +73,27 @@ class DerivChart extends StatefulWidget { this.crosshairVariant = CrosshairVariant.smallScreen, this.interactiveLayerBehaviour, this.useDrawingToolsV2 = false, + this.panelSizeRepo, + this.indicatorLabelIcons, Key? key, }) : super(key: key); /// Whether to use the new drawing tools v2 or not. final bool useDrawingToolsV2; + /// Persists the relative sizes of the main chart and bottom indicator + /// panels as the user drags the dividers between them. + /// + /// When omitted this widget creates and owns one, backed by + /// `SharedPreferences`, so panel sizes survive a reload out of the box. + final PanelSizeRepository? panelSizeRepo; + + /// Icons used by the on-chart indicator labels (eye, reorder arrows, + /// settings, delete and the expand/collapse chevron). + /// + /// Any icon left unset falls back to its Material default. + final IndicatorLabelIcons? indicatorLabelIcons; + /// Chart's main data series final DataSeries mainSeries; @@ -208,6 +230,8 @@ class _DerivChartState extends State { late AddOnsRepository _drawingToolsRepo; + final PanelSizeRepository _panelSizeRepo = PanelSizeRepository(); + final DrawingTools _drawingTools = DrawingTools(); late final InteractiveLayerBehaviour _interactiveLayerBehaviour; @@ -226,6 +250,23 @@ class _DerivChartState extends State { _initRepos(); } + @override + void dispose() { + // Only dispose the repo we created ourselves. When the host app supplies + // its own [PanelSizeRepository] it owns that instance's lifecycle. + if (widget.panelSizeRepo == null) { + _panelSizeRepo.dispose(); + } + super.dispose(); + } + + /// Loads saved panel sizes, if the host app hasn't supplied its own + /// [PanelSizeRepository]. + Future _loadPanelSizes() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + _panelSizeRepo.loadFromPrefs(prefs); + } + void _initRepos() { _indicatorsRepo = AddOnsRepository( createAddOn: (Map map) => IndicatorConfig.fromJson(map), @@ -239,6 +280,12 @@ class _DerivChartState extends State { onEditCallback: (_) {}, sharedPrefKey: widget.activeSymbol, ); + + if (widget.panelSizeRepo == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadPanelSizes(); + }); + } } @override @@ -283,6 +330,8 @@ class _DerivChartState extends State { annotations: widget.annotations, showCrosshair: widget.showCrosshair, indicatorsRepo: widget.indicatorsRepo ?? _indicatorsRepo, + panelSizeRepo: widget.panelSizeRepo ?? _panelSizeRepo, + indicatorLabelIcons: widget.indicatorLabelIcons, msPerPx: widget.msPerPx, minIntervalWidth: widget.minIntervalWidth, maxIntervalWidth: widget.maxIntervalWidth, diff --git a/lib/src/theme/chart_default_theme.dart b/lib/src/theme/chart_default_theme.dart index b67796d4b..7788db980 100644 --- a/lib/src/theme/chart_default_theme.dart +++ b/lib/src/theme/chart_default_theme.dart @@ -104,6 +104,15 @@ abstract class ChartDefaultTheme implements ChartTheme { double get crosshairInformationBoxContainerGlassBackgroundBlur => Dimens.crosshairInformationBoxContainerGlassBackgroundBlur; + @override + double get indicatorLabelIconSize => Dimens.indicatorLabelIconSize; + + @override + TextStyle get indicatorLabelTextStyle => textStyle( + textStyle: TextStyles.caption, + color: base01Color, + ); + @override TextStyle get crosshairInformationBoxTitleStyle => TextStyles.crosshairInformationBoxTitleStyle; diff --git a/lib/src/theme/chart_theme.dart b/lib/src/theme/chart_theme.dart index 31722c244..0055b537a 100644 --- a/lib/src/theme/chart_theme.dart +++ b/lib/src/theme/chart_theme.dart @@ -82,6 +82,16 @@ abstract class ChartTheme { double get crosshairInformationBoxContainerGlassBackgroundBlur; + /// Size of the action icons in the on-chart indicator labels. + /// + /// The default suits a phone-sized chart; a wider canvas makes the same + /// absolute size read as much smaller against its surroundings, so a host + /// rendering on one will usually want to raise this. + double get indicatorLabelIconSize; + + /// Text style of the title in the on-chart indicator labels. + TextStyle get indicatorLabelTextStyle; + TextStyle get crosshairInformationBoxTitleStyle; TextStyle get crosshairInformationBoxQuoteStyle; diff --git a/lib/src/theme/dimens.dart b/lib/src/theme/dimens.dart index 0d15d667e..d1bfb5dd7 100644 --- a/lib/src/theme/dimens.dart +++ b/lib/src/theme/dimens.dart @@ -82,4 +82,10 @@ class Dimens { /// the way to the fraction minimum). Reserving at least this much space /// keeps the row from being clipped by its [ClipRect] in those cases. static const double indicatorTitleBarMinHeight = 44; + + /// Default size of the action icons in an on-chart indicator label. + /// + /// Tuned against a phone-sized chart; a host rendering on a wider canvas + /// overrides `ChartTheme.indicatorLabelIconSize` rather than this. + static const double indicatorLabelIconSize = 16; } diff --git a/test/deriv_chart/chart/indicator_label_mobile_test.dart b/test/deriv_chart/chart/indicator_label_test.dart similarity index 94% rename from test/deriv_chart/chart/indicator_label_mobile_test.dart rename to test/deriv_chart/chart/indicator_label_test.dart index 6d3d0c6af..d74b1885c 100644 --- a/test/deriv_chart/chart/indicator_label_mobile_test.dart +++ b/test/deriv_chart/chart/indicator_label_test.dart @@ -1,4 +1,4 @@ -import 'package:deriv_chart/src/deriv_chart/chart/bottom_chart_mobile.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/bottom_chart_with_label.dart'; import 'package:deriv_chart/src/deriv_chart/chart/indicator_label_icons.dart'; import 'package:deriv_chart/src/theme/chart_default_light_theme.dart'; import 'package:deriv_chart/src/theme/chart_theme.dart'; @@ -15,12 +15,12 @@ void main() { ), ); - group('IndicatorLabelMobile', () { + group('IndicatorLabel', () { testWidgets('collapsed shows only the title and a chevron, no actions', (WidgetTester tester) async { await tester.pumpWidget( wrap( - const IndicatorLabelMobile( + const IndicatorLabel( title: 'RSI (14, C, Y)', isExpanded: false, showMoveUpIcon: false, @@ -44,7 +44,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'MA (50, C, MA, 0)', isExpanded: true, showMoveUpIcon: false, @@ -67,7 +67,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'MACD (12, 26, 9)', isExpanded: true, showMoveUpIcon: true, @@ -87,7 +87,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'RSI', isExpanded: true, showMoveUpIcon: false, @@ -108,7 +108,7 @@ void main() { int toggles = 0; await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'RSI', isExpanded: false, showMoveUpIcon: false, @@ -129,7 +129,7 @@ void main() { int toggles = 0; await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'RSI', isExpanded: false, showMoveUpIcon: false, @@ -157,7 +157,7 @@ void main() { await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'MA', isExpanded: true, showMoveUpIcon: false, @@ -188,7 +188,7 @@ void main() { (WidgetTester tester) async { await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'MA', isExpanded: true, showMoveUpIcon: false, @@ -212,7 +212,7 @@ void main() { int remove = 0; await tester.pumpWidget( wrap( - IndicatorLabelMobile( + IndicatorLabel( title: 'MA', isExpanded: true, showMoveUpIcon: false, diff --git a/test/deriv_chart/deriv_chart_lite_test.dart b/test/deriv_chart/deriv_chart_lite_test.dart new file mode 100644 index 000000000..84c641bc8 --- /dev/null +++ b/test/deriv_chart/deriv_chart_lite_test.dart @@ -0,0 +1,94 @@ +import 'package:deriv_chart/core_chart.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/chart.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/panel_size/panel_size_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// `DerivChartLite` is a thin wrapper that forwards its configuration to +/// [Chart]. Both parameters asserted here were missing from it until recently - +/// consumers on this entry point silently got no persisted panel sizes and no +/// custom indicator-label icons - and neither has any visible effect until the +/// chart renders on web, so a silent drop is easy to reintroduce and hard to +/// notice. These pin the wiring itself. +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + final List ticks = [ + const Tick(epoch: 1000, quote: 10), + const Tick(epoch: 2000, quote: 20), + ]; + + Widget app({ + PanelSizeRepository? panelSizeRepo, + IndicatorLabelIcons? indicatorLabelIcons, + }) => + MaterialApp( + home: Scaffold( + body: DerivChart( + mainSeries: LineSeries(ticks), + granularity: 1000, + activeSymbol: 'R_100', + panelSizeRepo: panelSizeRepo, + indicatorLabelIcons: indicatorLabelIcons, + ), + ), + ); + + /// Pumps [widget] and returns the [Chart] it built. + /// + /// Laying the chart out makes `BasicChart` drive its quote-bound + /// `AnimationController` from inside a `LayoutBuilder` callback, which the + /// test framework flags as "setState() called during build". That is + /// pre-existing behaviour of the chart itself, unrelated to the wiring under + /// test, so it is drained rather than left to fail the test. + Future pumpAndFindChart(WidgetTester tester, Widget widget) async { + await tester.pumpWidget(widget); + while (tester.takeException() != null) {} + return tester.widget(find.byType(Chart)); + } + + testWidgets('forwards a supplied panelSizeRepo to Chart', + (WidgetTester tester) async { + final PanelSizeRepository repo = PanelSizeRepository(); + addTearDown(repo.dispose); + + final Chart chart = + await pumpAndFindChart(tester, app(panelSizeRepo: repo)); + + expect(chart.panelSizeRepo, same(repo)); + }); + + testWidgets('falls back to an internally owned panelSizeRepo', + (WidgetTester tester) async { + final Chart chart = await pumpAndFindChart(tester, app()); + + // Not the host's, but still present - panel sizes persist by default. + expect(chart.panelSizeRepo, isNotNull); + }); + + testWidgets('forwards indicatorLabelIcons to Chart', + (WidgetTester tester) async { + const IndicatorLabelIcons icons = IndicatorLabelIcons( + show: Icons.star, + hide: Icons.star_border, + settings: Icons.tune, + delete: Icons.clear, + moveUp: Icons.north, + moveDown: Icons.south, + expandCollapse: Icons.expand_more, + ); + + final Chart chart = + await pumpAndFindChart(tester, app(indicatorLabelIcons: icons)); + + expect(chart.indicatorLabelIcons, same(icons)); + }); + + testWidgets('leaves indicatorLabelIcons null so Chart applies its defaults', + (WidgetTester tester) async { + final Chart chart = await pumpAndFindChart(tester, app()); + + expect(chart.indicatorLabelIcons, isNull); + }); +}