diff --git a/example/lib/main.dart b/example/lib/main.dart index b23817bcc..678f64a80 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -118,12 +118,22 @@ class _FullscreenChartState extends State { final InteractiveLayerController _interactiveLayerController = InteractiveLayerController(); + // Exposed explicitly (instead of relying on DerivChart's own default + // repo) so the "Add sample indicators" button below can seed a few + // non-overlay indicators with one tap, to quickly try out the resizable + // indicator panel dividers. Not persisted across app restarts. + final AddOnsRepository _indicatorsRepo = + AddOnsRepository( + createAddOn: (Map map) => IndicatorConfig.fromJson(map), + sharedPrefKey: 'example', + ); + late PrefServiceCache _prefService; TradeType _currentTradeType = TradeType.riseFall; // Dynamic marker duration in milliseconds - int _markerDurationMs = 1000 * 5 * 1 * 1; + int _markerDurationMs = 1000 * 20 * 1 * 1; // PnL label lifetime after marker end in milliseconds static const int _pnlLabelLifetimeMs = 4000; @@ -462,6 +472,7 @@ class _FullscreenChartState extends State { is connection_bloc.ConnectionConnectedState, connectionStatus: _buildConnectionStatus(), interactiveLayerController: _interactiveLayerController, + indicatorsRepo: _indicatorsRepo, ), ), _ActionButtonsRow( @@ -471,6 +482,7 @@ class _FullscreenChartState extends State { onUp: () => _addMarker(MarkerDirection.up), onDown: () => _addMarker(MarkerDirection.down), onClearMarkers: () => setState(_clearMarkers), + onAddSampleIndicators: _addSampleBottomIndicators, ), _BarriersControlsRow( onAddVerticalBarrier: () => setState( @@ -618,6 +630,25 @@ class _FullscreenChartState extends State { _tp = false; } + /// Adds a few sample bottom (non-overlay) indicators, so at least two + /// resizable dividers appear between the main chart and the indicator + /// panels without having to add indicators one-by-one via the dialog. + void _addSampleBottomIndicators() { + final Set existingTitles = _indicatorsRepo.items + .map((IndicatorConfig config) => config.title) + .toSet(); + + for (final IndicatorConfig config in const [ + RSIIndicatorConfig(), + MACDIndicatorConfig(), + StochasticOscillatorIndicatorConfig(), + ]) { + if (!existingTitles.contains(config.title)) { + _indicatorsRepo.add(config); + } + } + } + Widget _buildConnectionStatus() => ConnectionStatusLabel( text: _connectionBloc.state is connection_bloc.ConnectionErrorState // ignore: lines_longer_than_80_chars @@ -885,19 +916,6 @@ class _FullscreenChartState extends State { direction: marker.direction, markerType: MarkerType.entrySpot, ), - ChartMarker( - epoch: (endEpoch - marker.epoch) ~/ 2 + marker.epoch, - quote: marker.quote, - direction: marker.direction, - text: '1', - markerType: MarkerType.checkpointLine, - ), - ChartMarker( - epoch: (endEpoch - marker.epoch) ~/ 2 + marker.epoch, - quote: marker.quote, - direction: marker.direction, - markerType: MarkerType.checkpointLineCollapsed, - ), ChartMarker( epoch: endEpoch, quote: marker.quote, @@ -1044,6 +1062,7 @@ class _ChartSection extends StatelessWidget { required this.isConnected, required this.connectionStatus, required this.interactiveLayerController, + required this.indicatorsRepo, }); final InteractiveLayerBehaviour interactiveLayerBehaviour; @@ -1060,6 +1079,7 @@ class _ChartSection extends StatelessWidget { final bool isConnected; final Widget connectionStatus; final InteractiveLayerController interactiveLayerController; + final Repository indicatorsRepo; @override Widget build(BuildContext context) { @@ -1079,6 +1099,7 @@ class _ChartSection extends StatelessWidget { isLive: isLive, opacity: opacity, onVisibleAreaChanged: onVisibleAreaChanged, + indicatorsRepo: indicatorsRepo, ), ), if (!isConnected) @@ -1121,6 +1142,7 @@ class _ActionButtonsRow extends StatelessWidget { required this.onUp, required this.onDown, required this.onClearMarkers, + required this.onAddSampleIndicators, }); final VoidCallback onSettingsPressed; @@ -1129,6 +1151,7 @@ class _ActionButtonsRow extends StatelessWidget { final VoidCallback onUp; final VoidCallback onDown; final VoidCallback onClearMarkers; + final VoidCallback onAddSampleIndicators; @override Widget build(BuildContext context) { @@ -1169,6 +1192,11 @@ class _ActionButtonsRow extends StatelessWidget { icon: const Icon(Icons.delete), onPressed: onClearMarkers, ), + IconButton( + tooltip: 'Add sample indicators (to test resizable panels)', + icon: const Icon(Icons.stacked_line_chart), + onPressed: onAddSampleIndicators, + ), ], ), ); diff --git a/lib/deriv_chart.dart b/lib/deriv_chart.dart index ae59cabe2..bbe5c5310 100644 --- a/lib/deriv_chart.dart +++ b/lib/deriv_chart.dart @@ -43,6 +43,7 @@ export 'src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.dart' export 'src/add_ons/repository.dart'; export 'src/deriv_chart/interactive_layer/interactive_layer_export.dart'; export 'src/deriv_chart/chart/chart.dart'; +export 'src/deriv_chart/chart/panel_size/panel_size_repository.dart'; export 'src/deriv_chart/chart/data_visualization/annotations/barriers/accumulators_barriers/accumulators_active_contract.dart'; export 'src/deriv_chart/chart/data_visualization/annotations/barriers/accumulators_barriers/accumulators_closed_indicator.dart'; export 'src/deriv_chart/chart/data_visualization/annotations/barriers/accumulators_barriers/accumulators_entry_spot_barrier.dart'; diff --git a/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.dart b/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.dart index 6f81f9050..4217c0d15 100644 --- a/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.dart @@ -33,6 +33,7 @@ class ADXIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, showLastIndicator: showLastIndicator, @@ -82,6 +83,9 @@ class ADXIndicatorConfig extends IndicatorConfig { /// Histogram bar style final BarStyle barStyle; + @override + String get configSummary => '$period, $smoothingPeriod'; + @override Series getSeries(IndicatorInput indicatorInput) => ADXSeries( indicatorInput, @@ -119,6 +123,7 @@ class ADXIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => ADXIndicatorConfig( period: period ?? this.period, @@ -135,5 +140,6 @@ class ADXIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.g.dart index ae57adcc3..7495c6d1c 100644 --- a/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/adx/adx_indicator_config.g.dart @@ -32,11 +32,13 @@ ADXIndicatorConfig _$ADXIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$ADXIndicatorConfigToJson(ADXIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.dart b/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.dart index b3f0cf051..cf0b2ad8b 100644 --- a/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.dart @@ -32,6 +32,7 @@ class AlligatorIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, super.pipSize, }) : super( showLastIndicator: showLastIndicator, @@ -82,6 +83,9 @@ class AlligatorIndicatorConfig extends IndicatorConfig { /// Lips line style. final LineStyle lipsLineStyle; + @override + String get configSummary => '$jawPeriod, $teethPeriod, $lipsPeriod'; + @override Series getSeries(IndicatorInput indicatorInput) => AlligatorSeries( indicatorInput, @@ -129,6 +133,7 @@ class AlligatorIndicatorConfig extends IndicatorConfig { String? title, int? pipSize, int? number, + String? configId, }) => AlligatorIndicatorConfig( jawPeriod: jawPeriod ?? this.jawPeriod, @@ -145,6 +150,7 @@ class AlligatorIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, pipSize: pipSize ?? this.pipSize, ); } diff --git a/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.g.dart index cc3c79527..de10bbc6e 100644 --- a/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/alligator/alligator_indicator_config.g.dart @@ -29,6 +29,7 @@ AlligatorIndicatorConfig _$AlligatorIndicatorConfigFromJson( showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, pipSize: json['pipSize'] as int? ?? 4, ); @@ -36,6 +37,7 @@ Map _$AlligatorIndicatorConfigToJson( AlligatorIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.dart b/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.dart index 80fac90ea..b32412e16 100644 --- a/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.dart @@ -25,6 +25,7 @@ class AroonIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -52,6 +53,9 @@ class AroonIndicatorConfig extends IndicatorConfig { /// Down line style. final LineStyle downLineStyle; + @override + String get configSummary => '$period'; + @override Series getSeries(IndicatorInput indicatorInput) => AroonSeries( indicatorInput, @@ -79,6 +83,7 @@ class AroonIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => AroonIndicatorConfig( period: period ?? this.period, @@ -88,5 +93,6 @@ class AroonIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.g.dart index 05f86dcbc..5b965df40 100644 --- a/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/aroon/aroon_indicator_config.g.dart @@ -20,12 +20,14 @@ AroonIndicatorConfig _$AroonIndicatorConfigFromJson( showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$AroonIndicatorConfigToJson( AroonIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.dart b/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.dart index 7e0be4b97..1c67754f1 100644 --- a/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.dart @@ -21,6 +21,7 @@ class AwesomeOscillatorIndicatorConfig extends IndicatorConfig { int pipSize = 4, String? title, super.number, + super.configId, super.showLastIndicator, }) : super( isOverlay: false, @@ -68,6 +69,7 @@ class AwesomeOscillatorIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, int? pipSize, int? number, + String? configId, }) => AwesomeOscillatorIndicatorConfig( barStyle: barStyle ?? this.barStyle, @@ -75,5 +77,6 @@ class AwesomeOscillatorIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, pipSize: pipSize ?? this.pipSize, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.g.dart index a84ae0e7e..296cf92e0 100644 --- a/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/awesome_oscillator/awesome_oscillator_indicator_config.g.dart @@ -15,6 +15,7 @@ AwesomeOscillatorIndicatorConfig _$AwesomeOscillatorIndicatorConfigFromJson( pipSize: json['pipSize'] as int? ?? 4, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, showLastIndicator: json['showLastIndicator'] as bool? ?? false, ); @@ -22,6 +23,7 @@ Map _$AwesomeOscillatorIndicatorConfigToJson( AwesomeOscillatorIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.dart b/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.dart index 6bce1ae01..c00c06432 100644 --- a/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.dart @@ -33,6 +33,7 @@ class BollingerBandsIndicatorConfig extends MAIndicatorConfig { this.showChannelFill = true, bool showLastIndicator = false, super.number, + super.configId, }) : super( period: period, movingAverageType: movingAverageType, @@ -122,6 +123,7 @@ class BollingerBandsIndicatorConfig extends MAIndicatorConfig { bool? showChannelFill, bool? showLastIndicator, int? number, + String? configId, int? pipSize, bool? isOverlay, LineStyle? lineStyle, @@ -140,5 +142,6 @@ class BollingerBandsIndicatorConfig extends MAIndicatorConfig { showChannelFill: showChannelFill ?? this.showChannelFill, showLastIndicator: showLastIndicator ?? this.showLastIndicator, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.g.dart index 48b84b7dc..e1a60897c 100644 --- a/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/bollinger_bands/bollinger_bands_indicator_config.g.dart @@ -30,12 +30,14 @@ BollingerBandsIndicatorConfig _$BollingerBandsIndicatorConfigFromJson( showChannelFill: json['showChannelFill'] as bool? ?? true, showLastIndicator: json['showLastIndicator'] as bool? ?? false, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$BollingerBandsIndicatorConfigToJson( BollingerBandsIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'showLastIndicator': instance.showLastIndicator, 'period': instance.period, 'movingAverageType': diff --git a/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.dart b/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.dart index 1c1b0f646..ce52887dc 100644 --- a/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.dart @@ -30,6 +30,7 @@ class CCIIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -60,6 +61,9 @@ class CCIIndicatorConfig extends IndicatorConfig { /// Whether to paint overbought/sold zones fill. final bool showZones; + @override + String get configSummary => '$period'; + @override Series getSeries(IndicatorInput indicatorInput) => CCISeries( indicatorInput, @@ -94,6 +98,7 @@ class CCIIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => CCIIndicatorConfig( period: period ?? this.period, @@ -105,5 +110,6 @@ class CCIIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.g.dart index d9726e729..1d21e04e0 100644 --- a/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/commodity_channel_index/cci_indicator_config.g.dart @@ -22,11 +22,13 @@ CCIIndicatorConfig _$CCIIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$CCIIndicatorConfigToJson(CCIIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.dart b/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.dart index 797ff8d37..31860c7ec 100644 --- a/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.dart @@ -31,6 +31,7 @@ class DonchianChannelIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, super.pipSize, }) : super( title: title ?? DonchianChannelIndicatorConfig.name, @@ -69,6 +70,9 @@ class DonchianChannelIndicatorConfig extends IndicatorConfig { /// Fill color. final Color fillColor; + @override + String get configSummary => '$highPeriod, $lowPeriod'; + @override Series getSeries(IndicatorInput indicatorInput) => DonchianChannelsSeries.fromIndicator( @@ -102,6 +106,7 @@ class DonchianChannelIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, int? pipSize, }) => DonchianChannelIndicatorConfig( @@ -115,6 +120,7 @@ class DonchianChannelIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, pipSize: pipSize ?? this.pipSize, ); } diff --git a/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.g.dart index a2683d40c..c773947e8 100644 --- a/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/donchian_channel/donchian_channel_indicator_config.g.dart @@ -27,6 +27,7 @@ DonchianChannelIndicatorConfig _$DonchianChannelIndicatorConfigFromJson( showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, pipSize: json['pipSize'] as int? ?? 4, ); @@ -34,6 +35,7 @@ Map _$DonchianChannelIndicatorConfigToJson( DonchianChannelIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.dart b/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.dart index a2c45ec58..b12d5350f 100644 --- a/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.dart @@ -29,6 +29,7 @@ class DPOIndicatorConfig extends MAIndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( period: period, movingAverageType: movingAverageType, @@ -89,6 +90,7 @@ class DPOIndicatorConfig extends MAIndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, int? offset, bool? isOverlay, }) => @@ -102,5 +104,6 @@ class DPOIndicatorConfig extends MAIndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.g.dart index d3f37bebb..5fedc2f55 100644 --- a/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/dpo_indicator/dpo_indicator_config.g.dart @@ -21,11 +21,13 @@ DPOIndicatorConfig _$DPOIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$DPOIndicatorConfigToJson(DPOIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.dart b/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.dart index 1ce2c529f..a80f59ec0 100644 --- a/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.dart @@ -26,6 +26,7 @@ class FractalChaosBandIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, super.pipSize, }) : super( showLastIndicator: showLastIndicator, @@ -81,6 +82,7 @@ class FractalChaosBandIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, int? pipSize, }) => FractalChaosBandIndicatorConfig( @@ -91,6 +93,7 @@ class FractalChaosBandIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, pipSize: pipSize ?? this.pipSize, ); } diff --git a/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.g.dart index 04a7f8835..7a17afecf 100644 --- a/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/fcb_indicator/fcb_indicator_config.g.dart @@ -22,6 +22,7 @@ FractalChaosBandIndicatorConfig _$FractalChaosBandIndicatorConfigFromJson( showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, pipSize: json['pipSize'] as int? ?? 4, ); @@ -29,6 +30,7 @@ Map _$FractalChaosBandIndicatorConfigToJson( FractalChaosBandIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.dart b/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.dart index 84fdd420a..f5808c1c0 100644 --- a/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.dart @@ -28,6 +28,7 @@ class GatorIndicatorConfig extends IndicatorConfig { int pipSize = 4, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -66,6 +67,9 @@ class GatorIndicatorConfig extends IndicatorConfig { /// Histogram bar style final BarStyle barStyle; + @override + String get configSummary => '$jawPeriod, $teethPeriod, $lipsPeriod'; + @override Series getSeries(IndicatorInput indicatorInput) => GatorSeries( indicatorInput, @@ -101,6 +105,7 @@ class GatorIndicatorConfig extends IndicatorConfig { int? pipSize, String? title, int? number, + String? configId, bool? showLastIndicator, }) => GatorIndicatorConfig( @@ -114,5 +119,6 @@ class GatorIndicatorConfig extends IndicatorConfig { pipSize: pipSize ?? this.pipSize, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.g.dart index bfff2caae..9f0ce90d5 100644 --- a/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/gator/gator_indicator_config.g.dart @@ -21,12 +21,14 @@ GatorIndicatorConfig _$GatorIndicatorConfigFromJson( pipSize: json['pipSize'] as int? ?? 4, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$GatorIndicatorConfigToJson( GatorIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'pipSize': instance.pipSize, 'jawOffset': instance.jawOffset, diff --git a/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.dart b/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.dart index 2df95c242..3eb028f0f 100644 --- a/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.dart @@ -30,6 +30,7 @@ class IchimokuCloudIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( showLastIndicator: showLastIndicator, title: title ?? IchimokuCloudIndicatorConfig.name, @@ -73,6 +74,10 @@ class IchimokuCloudIndicatorConfig extends IndicatorConfig { /// Lagging line style. final LineStyle laggingLineStyle; + @override + String get configSummary => + '$conversionLinePeriod, $baseLinePeriod, $spanBPeriod'; + @override Series getSeries(IndicatorInput indicatorInput) => IchimokuCloudSeries(indicatorInput, @@ -109,6 +114,7 @@ class IchimokuCloudIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, int? pipSize, }) => IchimokuCloudIndicatorConfig( @@ -124,5 +130,6 @@ class IchimokuCloudIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.g.dart index cc0ab46f2..abff27611 100644 --- a/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/ichimoku_clouds/ichimoku_cloud_indicator_config.g.dart @@ -33,12 +33,14 @@ IchimokuCloudIndicatorConfig _$IchimokuCloudIndicatorConfigFromJson( showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$IchimokuCloudIndicatorConfigToJson( IchimokuCloudIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'conversionLinePeriod': instance.conversionLinePeriod, diff --git a/lib/src/add_ons/indicators_ui/indicator_config.dart b/lib/src/add_ons/indicators_ui/indicator_config.dart index 24e9a436c..aa3479b63 100644 --- a/lib/src/add_ons/indicators_ui/indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/indicator_config.dart @@ -39,6 +39,7 @@ abstract class IndicatorConfig extends AddOnConfig { this.showLastIndicator = false, this.pipSize = 4, super.number, + super.configId, }) : super(isOverlay: isOverlay); /// Creates a concrete indicator config from JSON. @@ -152,5 +153,6 @@ abstract class IndicatorConfig extends AddOnConfig { bool? showLastIndicator, int? pipSize, int? number, + String? configId, }); } diff --git a/lib/src/add_ons/indicators_ui/indicators_dialog.dart b/lib/src/add_ons/indicators_ui/indicators_dialog.dart index 14d6d5dca..fcf83b688 100644 --- a/lib/src/add_ons/indicators_ui/indicators_dialog.dart +++ b/lib/src/add_ons/indicators_ui/indicators_dialog.dart @@ -158,7 +158,11 @@ class _IndicatorsDialogState extends State { // the repository itself. final int postFixNumber = repo.getNumberForNewAddOn(config); - repo.add(config.copyWith(number: postFixNumber)); + repo.add(config.copyWith( + number: postFixNumber, + configId: + DateTime.now().millisecondsSinceEpoch.toString(), + )); setState(() {}); } : null, diff --git a/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.dart b/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.dart index 918c17ddc..8031cca21 100644 --- a/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.dart @@ -35,6 +35,7 @@ class MAEnvIndicatorConfig extends MAIndicatorConfig { this.showChannelFill = true, bool showLastIndicator = false, super.number, + super.configId, }) : super( period: period, movingAverageType: movingAverageType, @@ -115,6 +116,7 @@ class MAEnvIndicatorConfig extends MAIndicatorConfig { bool? showChannelFill, bool? showLastIndicator, int? number, + String? configId, int? pipSize, bool? isOverlay, int? offset, @@ -134,5 +136,6 @@ class MAEnvIndicatorConfig extends MAIndicatorConfig { showChannelFill: showChannelFill ?? this.showChannelFill, showLastIndicator: showLastIndicator ?? this.showLastIndicator, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.g.dart index 603282983..358eff7da 100644 --- a/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/ma_env_indicator/ma_env_indicator_config.g.dart @@ -32,12 +32,14 @@ MAEnvIndicatorConfig _$MAEnvIndicatorConfigFromJson( showChannelFill: json['showChannelFill'] as bool? ?? true, showLastIndicator: json['showLastIndicator'] as bool? ?? false, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$MAEnvIndicatorConfigToJson( MAEnvIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'showLastIndicator': instance.showLastIndicator, 'period': instance.period, 'movingAverageType': diff --git a/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.dart b/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.dart index 1a16d7259..633d779dc 100644 --- a/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.dart @@ -28,6 +28,7 @@ class MAIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : period = period ?? 50, movingAverageType = movingAverageType ?? MovingAverageType.simple, fieldType = fieldType ?? 'close', @@ -112,6 +113,7 @@ class MAIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => MAIndicatorConfig( period: period ?? this.period, @@ -124,5 +126,6 @@ class MAIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.g.dart index 4648996d7..58e93698c 100644 --- a/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/ma_indicator/ma_indicator_config.g.dart @@ -21,12 +21,14 @@ MAIndicatorConfig _$MAIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$MAIndicatorConfigToJson(MAIndicatorConfig instance) => { 'isOverlay': instance.isOverlay, 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.dart b/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.dart index 9a9795e68..0221cedc1 100644 --- a/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.dart @@ -29,6 +29,7 @@ class MACDIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -113,6 +114,7 @@ class MACDIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => MACDIndicatorConfig( fastMAPeriod: fastMAPeriod ?? this.fastMAPeriod, @@ -125,5 +127,6 @@ class MACDIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.g.dart index a70ecbb6d..877d672b8 100644 --- a/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/macd_indicator/macd_indicator_config.g.dart @@ -24,12 +24,14 @@ MACDIndicatorConfig _$MACDIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$MACDIndicatorConfigToJson( MACDIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.dart b/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.dart index 97f997107..f9583d408 100644 --- a/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.dart @@ -23,6 +23,7 @@ class ParabolicSARConfig extends IndicatorConfig { this.scatterStyle = const ScatterStyle(), String? title, super.number, + super.configId, }) : super(title: title ?? ParabolicSARConfig.name); /// Initializes from JSON. @@ -45,6 +46,9 @@ class ParabolicSARConfig extends IndicatorConfig { /// Scatter points style. final ScatterStyle scatterStyle; + @override + String get configSummary => '$minAccelerationFactor, $maxAccelerationFactor'; + @override Series getSeries(IndicatorInput indicatorInput) => ParabolicSARSeries( indicatorInput, @@ -70,6 +74,7 @@ class ParabolicSARConfig extends IndicatorConfig { ScatterStyle? scatterStyle, String? title, int? number, + String? configId, bool? showLastIndicator, int? pipSize, }) => @@ -81,5 +86,6 @@ class ParabolicSARConfig extends IndicatorConfig { scatterStyle: scatterStyle ?? this.scatterStyle, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.g.dart index 5f1c38427..082b2e728 100644 --- a/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/parabolic_sar/parabolic_sar_indicator_config.g.dart @@ -17,11 +17,13 @@ ParabolicSARConfig _$ParabolicSARConfigFromJson(Map json) => : ScatterStyle.fromJson(json['scatterStyle'] as Map), title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$ParabolicSARConfigToJson(ParabolicSARConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'minAccelerationFactor': instance.minAccelerationFactor, 'maxAccelerationFactor': instance.maxAccelerationFactor, diff --git a/lib/src/add_ons/indicators_ui/rainbow_indicator/rainbow_indicator_config.dart b/lib/src/add_ons/indicators_ui/rainbow_indicator/rainbow_indicator_config.dart index 073727f90..041dac648 100644 --- a/lib/src/add_ons/indicators_ui/rainbow_indicator/rainbow_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/rainbow_indicator/rainbow_indicator_config.dart @@ -29,6 +29,7 @@ class RainbowIndicatorConfig extends MAIndicatorConfig { this.rainbowLineStyles, bool showLastIndicator = false, super.number, + super.configId, }) : assert( rainbowLineStyles == null || rainbowLineStyles.length == bandsCount, '''If you provide [rainbowLineStyles] it should have the same length as [bandsCount]. @@ -111,6 +112,7 @@ class RainbowIndicatorConfig extends MAIndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, int? bandsCount, }) => RainbowIndicatorConfig( @@ -119,6 +121,7 @@ class RainbowIndicatorConfig extends MAIndicatorConfig { fieldType: fieldType ?? this.fieldType, showLastIndicator: showLastIndicator ?? this.showLastIndicator, number: number ?? this.number, + configId: configId ?? this.configId, bandsCount: bandsCount ?? this.bandsCount, rainbowLineStyles: rainbowLineStyles ?? this.rainbowLineStyles, ); diff --git a/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.dart b/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.dart index 2f566f144..33df17c7c 100644 --- a/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.dart @@ -24,6 +24,7 @@ class ROCIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -51,6 +52,9 @@ class ROCIndicatorConfig extends IndicatorConfig { /// Line style. final LineStyle? lineStyle; + @override + String get configSummary => '$period'; + @override Series getSeries(IndicatorInput indicatorInput) => ROCSeries.fromIndicator( IndicatorConfig.supportedFieldTypes[fieldType]!(indicatorInput), @@ -82,6 +86,7 @@ class ROCIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => ROCIndicatorConfig( period: period ?? this.period, @@ -91,5 +96,6 @@ class ROCIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.g.dart index 7ec5ab725..149d13b11 100644 --- a/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/roc/roc_indicator_config.g.dart @@ -17,11 +17,13 @@ ROCIndicatorConfig _$ROCIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$ROCIndicatorConfigToJson(ROCIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.dart b/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.dart index a0cbad779..e414eeb71 100644 --- a/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.dart @@ -32,6 +32,7 @@ class RSIIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -115,6 +116,7 @@ class RSIIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => RSIIndicatorConfig( period: period ?? this.period, @@ -128,5 +130,6 @@ class RSIIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.g.dart index d993f44aa..18d04a659 100644 --- a/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/rsi/rsi_indicator_config.g.dart @@ -23,11 +23,13 @@ RSIIndicatorConfig _$RSIIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$RSIIndicatorConfigToJson(RSIIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.dart b/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.dart index ee7868db2..74a963542 100644 --- a/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.dart @@ -39,6 +39,7 @@ class SMIIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -84,6 +85,10 @@ class SMIIndicatorConfig extends IndicatorConfig { /// Signal line style. final LineStyle? signalLineStyle; + @override + String get configSummary => + '$period, $smoothingPeriod, $doubleSmoothingPeriod, $signalPeriod'; + @override Series getSeries(IndicatorInput indicatorInput) => SMISeries( indicatorInput, @@ -129,6 +134,7 @@ class SMIIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => SMIIndicatorConfig( period: period ?? this.period, @@ -145,5 +151,6 @@ class SMIIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.g.dart index 3baf45a56..a52ff701e 100644 --- a/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/smi/smi_indicator_config.g.dart @@ -33,11 +33,13 @@ SMIIndicatorConfig _$SMIIndicatorConfigFromJson(Map json) => showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$SMIIndicatorConfigToJson(SMIIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.dart b/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.dart index f670f8f0f..ebb8bc974 100644 --- a/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.dart @@ -36,6 +36,7 @@ class StochasticOscillatorIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -88,6 +89,9 @@ class StochasticOscillatorIndicatorConfig extends IndicatorConfig { /// Slow line style. final LineStyle slowLineStyle; + @override + String get configSummary => '$period'; + @override Series getSeries(IndicatorInput indicatorInput) => StochasticOscillatorSeries( IndicatorConfig.supportedFieldTypes[fieldType]!(indicatorInput), this, @@ -123,6 +127,7 @@ class StochasticOscillatorIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => StochasticOscillatorIndicatorConfig( period: period ?? this.period, @@ -140,5 +145,6 @@ class StochasticOscillatorIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.g.dart index b7f2bb35d..97bc1d66c 100644 --- a/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/stochastic_oscillator_indicator/stochastic_oscillator_indicator_config.g.dart @@ -35,12 +35,14 @@ StochasticOscillatorIndicatorConfig showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$StochasticOscillatorIndicatorConfigToJson( StochasticOscillatorIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.dart b/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.dart index c660acde4..244f764c6 100644 --- a/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.dart @@ -31,6 +31,7 @@ class WilliamsRIndicatorConfig extends IndicatorConfig { bool showLastIndicator = false, String? title, super.number, + super.configId, }) : super( isOverlay: false, pipSize: pipSize, @@ -64,6 +65,9 @@ class WilliamsRIndicatorConfig extends IndicatorConfig { /// To show overbought/sold lines and intersection zones with the indicator. final bool showZones; + @override + String get configSummary => '$period'; + @override Series getSeries(IndicatorInput indicatorInput) => WilliamsRSeries( indicatorInput, @@ -102,6 +106,7 @@ class WilliamsRIndicatorConfig extends IndicatorConfig { bool? showLastIndicator, String? title, int? number, + String? configId, }) => WilliamsRIndicatorConfig( period: period ?? this.period, @@ -114,5 +119,6 @@ class WilliamsRIndicatorConfig extends IndicatorConfig { showLastIndicator: showLastIndicator ?? this.showLastIndicator, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.g.dart index c21e3d75b..696241eda 100644 --- a/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/williams_r/williams_r_indicator_config.g.dart @@ -27,12 +27,14 @@ WilliamsRIndicatorConfig _$WilliamsRIndicatorConfigFromJson( showLastIndicator: json['showLastIndicator'] as bool? ?? false, title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$WilliamsRIndicatorConfigToJson( WilliamsRIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'showLastIndicator': instance.showLastIndicator, 'pipSize': instance.pipSize, diff --git a/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.dart b/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.dart index 3915114e7..e8e60d4b7 100644 --- a/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.dart +++ b/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.dart @@ -21,6 +21,7 @@ class ZigZagIndicatorConfig extends IndicatorConfig { this.lineStyle = const LineStyle(thickness: 0.9, color: Colors.blue), String? title, super.number, + super.configId, }) : super(title: title ?? ZigZagIndicatorConfig.name); /// Initializes from JSON. @@ -40,6 +41,9 @@ class ZigZagIndicatorConfig extends IndicatorConfig { /// ZigZag line style final LineStyle lineStyle; + @override + String get configSummary => '$distance'; + @override Series getSeries(IndicatorInput indicatorInput) => ZigZagSeries( indicatorInput, @@ -64,6 +68,7 @@ class ZigZagIndicatorConfig extends IndicatorConfig { LineStyle? lineStyle, String? title, int? number, + String? configId, bool? showLastIndicator, int? pipSize, }) => @@ -72,5 +77,6 @@ class ZigZagIndicatorConfig extends IndicatorConfig { lineStyle: lineStyle ?? this.lineStyle, title: title ?? this.title, number: number ?? this.number, + configId: configId ?? this.configId, ); } diff --git a/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.g.dart b/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.g.dart index 09cfef394..ad93a7a51 100644 --- a/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.g.dart +++ b/lib/src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.g.dart @@ -15,12 +15,14 @@ ZigZagIndicatorConfig _$ZigZagIndicatorConfigFromJson( : LineStyle.fromJson(json['lineStyle'] as Map), title: json['title'] as String?, number: json['number'] as int? ?? 0, + configId: json['configId'] as String?, ); Map _$ZigZagIndicatorConfigToJson( ZigZagIndicatorConfig instance) => { 'number': instance.number, + 'configId': instance.configId, 'title': instance.title, 'distance': instance.distance, 'lineStyle': instance.lineStyle, diff --git a/lib/src/deriv_chart/chart/bottom_chart.dart b/lib/src/deriv_chart/chart/bottom_chart.dart index b82d5533f..db4ee53ce 100644 --- a/lib/src/deriv_chart/chart/bottom_chart.dart +++ b/lib/src/deriv_chart/chart/bottom_chart.dart @@ -196,16 +196,7 @@ class _BottomChartState extends BasicChartState { child: ClipRect( child: Stack( children: [ - Column( - children: [ - Divider( - height: 0.5, - thickness: 1, - color: theme.base01Color, - ), - Expanded(child: super.build(context)), - ], - ), + super.build(context), // if (kIsWeb) _buildCrosshairAreaWeb(), // TODO(Jim): Address the crosshair area for web indicators. _buildBottomChartOptions(context) ], diff --git a/lib/src/deriv_chart/chart/bottom_chart_mobile.dart b/lib/src/deriv_chart/chart/bottom_chart_mobile.dart index bfb175142..3cf8a2734 100644 --- a/lib/src/deriv_chart/chart/bottom_chart_mobile.dart +++ b/lib/src/deriv_chart/chart/bottom_chart_mobile.dart @@ -80,25 +80,14 @@ class _BottomChartMobileState extends BasicChartState { value: chartConfig, child: ClipRect( child: widget.isHidden - ? Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: _buildCollapsedBottomChart(context), - ), - _buildDivider(), - ], + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _buildCollapsedBottomChart(context), ) : Stack( children: [ if (widget.showFrame) _buildChartFrame(context), - if (!widget.isHidden) - Column( - children: [ - Expanded(child: super.build(context)), - _buildDivider(), - ], - ), + if (!widget.isHidden) super.build(context), Positioned( top: 4, left: widget.bottomChartTitleMargin?.left ?? 10, @@ -129,12 +118,6 @@ class _BottomChartMobileState extends BasicChartState { onSwap: widget.onSwap, ); - Widget _buildDivider() => const Divider( - height: 0.5, - thickness: 1, - color: LegacyLightThemeColors.hover, - ); - Widget _buildCollapsedBottomChart(BuildContext context) => Container( alignment: Alignment.topLeft, child: Padding( diff --git a/lib/src/deriv_chart/chart/chart.dart b/lib/src/deriv_chart/chart/chart.dart index f608b7bda..cdbaba4e6 100644 --- a/lib/src/deriv_chart/chart/chart.dart +++ b/lib/src/deriv_chart/chart/chart.dart @@ -1,6 +1,9 @@ -import 'package:collection/collection.dart'; +import 'dart:math' as math; + import 'package:deriv_chart/src/deriv_chart/chart/data_visualization/models/chart_scale_model.dart'; import 'package:deriv_chart/src/deriv_chart/chart/mobile_chart_frame_dividers.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/panel_size/panel_size_repository.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/resizable_chart_divider.dart'; import 'package:deriv_chart/src/deriv_chart/chart/x_axis/x_axis_model.dart'; import 'package:deriv_chart/src/deriv_chart/interactive_layer/crosshair/crosshair_variant.dart'; import 'package:deriv_chart/src/theme/dimens.dart'; @@ -39,6 +42,123 @@ part 'chart_state_mobile.dart'; const Duration _defaultDuration = Duration(milliseconds: 300); +/// Ensures [fractions] has exactly one entry per key in [keys], seeding new +/// keys from [saved] (if previously persisted) or from [defaultFraction], +/// dropping any key no longer present in [keys], and renormalizing so the +/// remaining fractions always sum to `1.0`. +/// +/// [fractions] is expected to represent one independent group of sibling +/// panels whose heights add up to the full space they share - e.g. the main +/// chart plus every bottom panel, or (on mobile) the individual indicator +/// panels sharing the bottom section. +/// +/// `SharedPreferences` access backing [saved] is async, so a chart's very +/// first build always runs before it has actually loaded - every key gets +/// seeded from [defaultFraction] since [saved] is still empty at that point. +/// Set [forceApplySaved] once that load has completed (see +/// [PanelSizeRepository.loadGeneration]) to overwrite already-seeded keys +/// with the real saved values instead of leaving them stuck at the +/// placeholder defaults; leave it `false` on every other build so a value +/// the user is actively resizing isn't clobbered by a stale saved fraction. +void syncPanelFractions( + Map fractions, + List keys, + Map saved, + double Function(String key) defaultFraction, { + bool forceApplySaved = false, +}) { + final Set keySet = keys.toSet(); + fractions.removeWhere((String key, _) => !keySet.contains(key)); + + for (final String key in keys) { + if (forceApplySaved && saved.containsKey(key)) { + fractions[key] = saved[key]!; + } else { + fractions[key] ??= saved[key] ?? defaultFraction(key); + } + } + + final double sum = fractions.values.fold(0, (double a, double b) => a + b); + if (sum > 0 && sum != 1.0) { + fractions.updateAll((_, double value) => value / sum); + } +} + +/// Cascading resize: dragging the divider between `orderedKeys[dividerIndex]` +/// and `orderedKeys[dividerIndex + 1]` grows one side by [deltaFraction] and +/// shrinks the other. A positive [deltaFraction] grows +/// `orderedKeys[dividerIndex]`; a negative one grows +/// `orderedKeys[dividerIndex + 1]`. +/// +/// The space to shrink is taken from the nearest neighbor on the shrinking +/// side first; if that neighbor is already at the minimum height (a fixed +/// share of the total, [Dimens.minChartPanelHeightFraction], rather than a +/// fixed pixel amount - so it scales down on small screens instead of +/// eating an unreasonably large share of them), the remainder cascades +/// further down the chain (e.g. dragging the divider between a second and +/// third panel can shrink the first panel too, once the second has nothing +/// left to give) so resizing never gets stuck behind an in-between panel +/// that's already at its minimum. Returns whether [fractions] was changed. +/// +/// [usableHeight] converts [Dimens.indicatorTitleBarMinHeight] - the fixed +/// pixel floor a panel is actually rendered at (see +/// `_ChartStateMobile.getBottomIndicatorsList`) - into a fraction, so a +/// donor never gives up more than what its title bar's visible floor +/// already stopped it from losing on screen. Without this, a donor already +/// pinned at that pixel floor would keep silently losing fraction as the +/// drag continued past it - invisibly inflating the recipient without the +/// donor appearing to shrink any further. +bool resizeCascadingFractions( + Map fractions, + List orderedKeys, + int dividerIndex, + double deltaFraction, { + double usableHeight = double.infinity, +}) { + if (deltaFraction == 0) { + return false; + } + + final double minFraction = math.max( + Dimens.minChartPanelHeightFraction, + Dimens.indicatorTitleBarMinHeight / usableHeight, + ); + + final String recipientKey; + final List donorChain; + if (deltaFraction > 0) { + recipientKey = orderedKeys[dividerIndex]; + donorChain = orderedKeys.sublist(dividerIndex + 1); + } else { + recipientKey = orderedKeys[dividerIndex + 1]; + donorChain = orderedKeys.sublist(0, dividerIndex + 1).reversed.toList(); + } + + double remaining = deltaFraction.abs(); + double actualDelta = 0; + for (final String donor in donorChain) { + if (remaining <= 0) { + break; + } + final double current = fractions[donor] ?? 0; + final double avail = current - minFraction; + if (avail <= 0) { + continue; + } + final double take = avail < remaining ? avail : remaining; + fractions[donor] = current - take; + remaining -= take; + actualDelta += take; + } + + if (actualDelta <= 0) { + return false; + } + + fractions[recipientKey] = (fractions[recipientKey] ?? 0) + actualDelta; + return true; +} + /// Interactive chart widget. class Chart extends StatefulWidget { /// Creates chart that expands to available space. @@ -79,6 +199,7 @@ class Chart extends StatefulWidget { this.showScrollToLastTickButton, this.loadingAnimationColor, this.useDrawingToolsV2 = false, + this.panelSizeRepo, Key? key, }) : super(key: key); @@ -202,6 +323,10 @@ class Chart extends StatefulWidget { /// The interactive layer behaviour. final InteractiveLayerBehaviour? interactiveLayerBehaviour; + /// Persists the relative sizes of the main chart and bottom indicator + /// panels as the user drags [ResizableChartDivider]s between them. + final PanelSizeRepository? panelSizeRepo; + @override State createState() => // TODO(Ramin): Make this customizable from outside. @@ -216,11 +341,32 @@ abstract class _ChartState extends State with WidgetsBindingObserver { late List? bottomSeries; int? expandedIndex; + /// Current fraction of the available height occupied by each chart panel, + /// keyed by [PanelSizeRepository.mainPanelKey] for the main chart and by + /// [IndicatorConfig.configId] for each bottom indicator panel. + final Map _panelFractions = {}; + + /// The [PanelSizeRepository.loadGeneration] already applied to + /// [_panelFractions], or `null` if none has been applied yet. See + /// [_syncPanelFractions]. + int? _appliedPanelSizeGeneration; + @override void initState() { super.initState(); WidgetsFlutterBinding.ensureInitialized().addObserver(this); _initChartController(); + widget.panelSizeRepo?.addListener(_onPanelSizeRepoChanged); + } + + /// `PanelSizeRepository.loadFromPrefs` completes asynchronously, so this + /// forces a rebuild once it has (see [_syncPanelFractions]) - otherwise a + /// load that finishes after this chart's first build would silently never + /// reach the screen. + void _onPanelSizeRepoChanged() { + if (mounted) { + setState(() {}); + } } @override @@ -255,6 +401,91 @@ abstract class _ChartState extends State with WidgetsBindingObserver { : ChartDefaultLightTheme()); } + /// Ensures [_panelFractions] has exactly one entry per key in [keys], + /// seeding new keys from [widget.panelSizeRepo] (if previously saved) or + /// from [defaultFraction], dropping stale keys, and renormalizing so the + /// fractions always sum to `1.0`. + /// + /// [widget.panelSizeRepo]'s load is async, so the first call here (during + /// this chart's very first build) always seeds every key from + /// [defaultFraction] since nothing has loaded yet. Once + /// [PanelSizeRepository.loadGeneration] moves past whatever generation was + /// last applied - flagged via [_onPanelSizeRepoChanged] forcing a rebuild - + /// this overwrites those placeholder defaults with the real saved values + /// exactly once per load, instead of leaving them stuck. + void _syncPanelFractions( + List keys, + double Function(String key) defaultFraction, + ) { + final PanelSizeRepository? repo = widget.panelSizeRepo; + final bool forceApplySaved = repo != null && + repo.loadGeneration > 0 && + repo.loadGeneration != _appliedPanelSizeGeneration; + if (forceApplySaved) { + _appliedPanelSizeGeneration = repo.loadGeneration; + } + + syncPanelFractions( + _panelFractions, + keys, + repo?.fractions ?? const {}, + defaultFraction, + forceApplySaved: forceApplySaved, + ); + } + + /// Cascading resize of the divider at [dividerIndex] within + /// [_panelFractions]. See [resizeCascadingFractions]. + void _resizeCascadingPanels( + List orderedKeys, + int dividerIndex, + double deltaFraction, { + double usableHeight = double.infinity, + }) { + if (resizeCascadingFractions( + _panelFractions, + orderedKeys, + dividerIndex, + deltaFraction, + usableHeight: usableHeight, + )) { + setState(() {}); + } + } + + /// Persists [_panelFractions], merged with any [extraFractions] (used by + /// mobile to also persist the relative sizes of individual bottom + /// indicator panels, which are tracked in a separate map). + void _persistPanelFractions([ + Map extraFractions = const {}, + ]) { + widget.panelSizeRepo?.save({ + ..._panelFractions, + ...extraFractions, + }); + } + + /// Key for [config]'s panel within [_panelFractions]/[PanelSizeRepository]. + /// + /// Prefers [AddOnConfig.configId] - a fresh id assigned when the indicator + /// is added (see `IndicatorsDialog`'s "Add" handler) - since a positional + /// or title/number-based key would tie a saved size to whatever happens to + /// look the same, meaning deleting an indicator and adding a new one of + /// the same type back could silently inherit the deleted one's size. + /// Falls back to [IndicatorConfig.title] + [AddOnConfig.number] for + /// indicators added without a [configId] (e.g. persisted from before this + /// existed, or added directly by a host app rather than through the + /// indicators dialog). + String _panelKeyFor(IndicatorConfig config) => + config.configId ?? '${config.title}#${config.number}'; + + /// Height available for panel fractions once [dividerCount] + /// [ResizableChartDivider]s - which take up real space in the same + /// Column as the panels - have been subtracted from [totalHeight]. + double _usableHeightFor(double totalHeight, int dividerCount) => + (totalHeight - dividerCount * Dimens.chartPanelDividerHitHeight) + .clamp(0.0, double.infinity); + void _onCrosshairHover( Offset globalPosition, Offset localPosition, @@ -410,6 +641,7 @@ abstract class _ChartState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsFlutterBinding.ensureInitialized().removeObserver(this); + widget.panelSizeRepo?.removeListener(_onPanelSizeRepoChanged); super.dispose(); } @@ -417,6 +649,12 @@ abstract class _ChartState extends State with WidgetsBindingObserver { void didUpdateWidget(covariant Chart oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.panelSizeRepo != oldWidget.panelSizeRepo) { + oldWidget.panelSizeRepo?.removeListener(_onPanelSizeRepoChanged); + widget.panelSizeRepo?.addListener(_onPanelSizeRepoChanged); + _appliedPanelSizeGeneration = null; + } + // if controller is set if (widget.controller != oldWidget.controller) { _initChartController(); diff --git a/lib/src/deriv_chart/chart/chart_state_mobile.dart b/lib/src/deriv_chart/chart/chart_state_mobile.dart index f2326e93f..d33bd74e3 100644 --- a/lib/src/deriv_chart/chart/chart_state_mobile.dart +++ b/lib/src/deriv_chart/chart/chart_state_mobile.dart @@ -1,27 +1,6 @@ part of 'chart.dart'; class _ChartStateMobile extends _ChartState { - double _bottomSectionHeight = 0; - - @override - void initState() { - super.initState(); - - _bottomSectionHeight = - _getBottomIndicatorsSectionHeightFraction(widget.bottomConfigs.length); - } - - @override - void didUpdateWidget(covariant Chart oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.bottomConfigs.length != widget.bottomConfigs.length) { - _bottomSectionHeight = _getBottomIndicatorsSectionHeightFraction( - widget.bottomConfigs.length, - ); - } - } - @override Widget buildChartsLayout( BuildContext context, @@ -34,54 +13,149 @@ class _ChartStateMobile extends _ChartState { final Duration quoteBoundsAnimationDuration = widget.quoteBoundsAnimationDuration ?? _defaultDuration; - List getBottomIndicatorsList(BuildContext context) => - widget.indicatorsRepo!.items - .mapIndexed((int index, IndicatorConfig config) { - if (config.isOverlay) { - return const SizedBox.shrink(); - } - - final Series series = config.getSeries( - IndicatorInput( - widget.mainSeries.input, - widget.granularity, + final Repository? repository = widget.indicatorsRepo; + + // Bottom (non-overlay) indicators, in repo order, keeping their true + // index within `repository.items` (needed for hidden-status lookups). + final List bottomRepoIndices = [ + if (repository != null) + for (int i = 0; i < repository.items.length; i++) + if (!repository.items[i].isOverlay) i + ]; + + // Every bottom indicator's key, visible or hidden, in repo order. + // Syncing/seeding fractions against this full set (rather than just the + // visible ones) is what makes a hidden indicator's fraction survive + // hiding it - since [syncPanelFractions] only drops/renormalizes keys + // that are absent from the list it's given, a hidden indicator's key + // staying in this list keeps its stored fraction untouched until it's + // actually removed from the repo, so unhiding it restores the exact + // size it had before. + final List allBottomIndicatorKeys = bottomRepoIndices + .map((int i) => _panelKeyFor(repository!.items[i])) + .toList(); + + // A single flat, ordered chain covering the main chart and every bottom + // indicator panel, visible or hidden. Keeping every panel - including + // hidden ones - in this chain (rather than resizing the bottom section + // as a whole and then splitting it among indicators independently) is + // what lets a resize cascade past a panel already at its minimum height + // into the next one that still has room, no matter which divider is + // being dragged, and lets a hidden panel's divider still resize it even + // though its content is collapsed. + final List orderedKeys = [ + PanelSizeRepository.mainPanelKey, + ...allBottomIndicatorKeys, + ]; + + final double bottomSectionDefaultFraction = + _getBottomIndicatorsSectionHeightFraction(bottomRepoIndices.length); + + _syncPanelFractions( + [PanelSizeRepository.mainPanelKey, ...allBottomIndicatorKeys], + (String key) => key == PanelSizeRepository.mainPanelKey + ? 1 - bottomSectionDefaultFraction + : bottomSectionDefaultFraction / + (allBottomIndicatorKeys.isEmpty + ? 1 + : allBottomIndicatorKeys.length), + ); + + List getBottomIndicatorsList( + BuildContext context, + double usableHeight, + ) { + final List children = []; + int position = 0; + + for (final int repoIndex in bottomRepoIndices) { + final IndicatorConfig config = repository!.items[repoIndex]; + final bool isHidden = repository.getHiddenStatus(repoIndex); + + final Series series = config.getSeries( + IndicatorInput( + widget.mainSeries.input, + widget.granularity, + ), + ); + + // TODO(Ramin): Use the key (type + number) once it's implemented. + final int indexInBottomConfigs = + referenceIndexOf(widget.bottomConfigs, config); + + final Widget bottomChart = BottomChartMobile( + series: series, + isHidden: isHidden, + granularity: widget.granularity, + pipSize: config.pipSize, + title: + '${config.shortTitle} ${config.number > 0 ? config.number : ''}' + '${config.configSummary.isEmpty ? '' : ' (${config.configSummary})'}', + currentTickAnimationDuration: currentTickAnimationDuration, + quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, + bottomChartTitleMargin: const EdgeInsets.only(left: Dimens.margin04), + onHideUnhideToggle: () => + _onIndicatorHideToggleTapped(repository, repoIndex), + onSwap: (int offset) => _onSwap( + config, widget.bottomConfigs[indexInBottomConfigs + offset]), + showMoveUpIcon: bottomSeries!.length > 1 && indexInBottomConfigs != 0, + showMoveDownIcon: bottomSeries.length > 1 && + indexInBottomConfigs != bottomSeries.length - 1, + showFrame: context.read().chartAxisConfig.showFrame, + ); + + final String key = _panelKeyFor(config); + + // Every panel - hidden or visible - keeps its own divider and + // reserves the exact height its fraction represents, so hiding one + // only collapses its *content* down to the title bar; its divider + // stays in place and its space keeps being resizable (and cascades + // into neighboring panels the same way a visible one would), so + // nothing shifts or resizes as a side effect of hiding/unhiding it. + // + // The divider directly above this panel sits between + // `orderedKeys[position]` (main, or the previous indicator) and + // `orderedKeys[position + 1]` (this indicator) - i.e. its divider + // index within the shared chain is `position`. + final int dividerIndex = position; + + // Every panel's title bar (name, hide/unhide, up/down icons) is made + // up of fixed-size text and icons rather than freely-scalable chart + // content, so its fraction - whether it's the *stored* one kept as- + // is while hidden, or one dragged down toward + // [Dimens.minChartPanelHeightFraction] while visible - can still + // work out to less pixel height than that title bar needs. Flooring + // only the rendered height (not the stored fraction) keeps it 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( + orderedKeys, + dividerIndex, + deltaPixels / usableHeight, + usableHeight: usableHeight, + ), + onDragEnd: _persistPanelFractions, + ), + ) + ..add( + SizedBox( + height: renderedHeight, + child: bottomChart, ), - ); - final Repository? repository = widget.indicatorsRepo; - - // TODO(Ramin): Use the key (type + number) once it's implemented. - final int indexInBottomConfigs = - referenceIndexOf(widget.bottomConfigs, config); - - final Widget bottomChart = BottomChartMobile( - series: series, - isHidden: repository?.getHiddenStatus(index) ?? false, - granularity: widget.granularity, - pipSize: config.pipSize, - title: - '${config.shortTitle} ${config.number > 0 ? config.number : ''}' - ' (${config.configSummary})', - currentTickAnimationDuration: currentTickAnimationDuration, - quoteBoundsAnimationDuration: quoteBoundsAnimationDuration, - bottomChartTitleMargin: - const EdgeInsets.only(left: Dimens.margin04), - onHideUnhideToggle: () => - _onIndicatorHideToggleTapped(repository, index), - onSwap: (int offset) => _onSwap( - config, widget.bottomConfigs[indexInBottomConfigs + offset]), - showMoveUpIcon: - bottomSeries!.length > 1 && indexInBottomConfigs != 0, - showMoveDownIcon: bottomSeries.length > 1 && - indexInBottomConfigs != bottomSeries.length - 1, - showFrame: context.read().chartAxisConfig.showFrame, ); - return (repository?.getHiddenStatus(index) ?? false) - ? bottomChart - : Expanded( - child: bottomChart, - ); - }).toList(); + position++; + } + + return children; + } final List overlaySeries = []; @@ -105,8 +179,18 @@ class _ChartStateMobile extends _ChartState { BuildContext context, BoxConstraints constraints, ) { + final double availableHeight = constraints.maxHeight; + + // Each divider takes up real space in the bottom section's Column + // alongside the indicator panels, so it must be subtracted from the + // space panel fractions are applied to. Every bottom indicator now + // keeps its divider whether hidden or visible, so all of them count. + final double usableHeight = + _usableHeightFor(availableHeight, bottomRepoIndices.length); + final List bottomIndicatorsList = - getBottomIndicatorsList(context); + getBottomIndicatorsList(context, usableHeight); + return Column( children: [ Expanded( @@ -158,20 +242,7 @@ class _ChartStateMobile extends _ChartState { ], ), ), - if (context.read().chartAxisConfig.showFrame && - bottomIndicatorsList.isNotEmpty) - const Divider( - height: 0.5, - thickness: 1, - color: Color(0xFF242828), - ), - if (_isAllBottomIndicatorsHidden) - ...bottomIndicatorsList - else - SizedBox( - height: _bottomSectionHeight * constraints.maxHeight, - child: Column(children: bottomIndicatorsList), - ), + ...bottomIndicatorsList, ], ); }); @@ -208,17 +279,6 @@ class _ChartStateMobile extends _ChartState { double _getBottomIndicatorsSectionHeightFraction(int bottomIndicatorsCount) => 1 - (0.65 - 0.125 * (bottomIndicatorsCount - 1)); - bool get _isAllBottomIndicatorsHidden { - bool isAllHidden = true; - for (int i = 0; i < widget.indicatorsRepo!.items.length; i++) { - if (!widget.indicatorsRepo!.items[i].isOverlay && - !(widget.indicatorsRepo?.getHiddenStatus(i) ?? false)) { - isAllHidden = false; - } - } - return isAllHidden; - } - Widget _buildOverlayIndicatorsLabels() { final List overlayIndicatorsLabels = []; if (widget.indicatorsRepo != null) { @@ -234,7 +294,7 @@ class _ChartStateMobile extends _ChartState { child: IndicatorLabelMobile( title: '${config.shortTitle} ${config.number > 0 ? config.number : ''}' - ' (${config.configSummary})', + '${config.configSummary.isEmpty ? '' : ' (${config.configSummary})'}', showMoveUpIcon: false, showMoveDownIcon: false, isHidden: widget.indicatorsRepo?.getHiddenStatus(i) ?? false, diff --git a/lib/src/deriv_chart/chart/chart_state_web.dart b/lib/src/deriv_chart/chart/chart_state_web.dart index ba89e3214..14be0fe31 100644 --- a/lib/src/deriv_chart/chart/chart_state_web.dart +++ b/lib/src/deriv_chart/chart/chart_state_web.dart @@ -1,6 +1,9 @@ part of 'chart.dart'; class _ChartStateWeb extends _ChartState { + String _bottomPanelKey(int index) => + _panelKeyFor(widget.bottomConfigs[index]); + @override Widget buildChartsLayout( BuildContext context, @@ -14,53 +17,109 @@ class _ChartStateWeb extends _ChartState { widget.quoteBoundsAnimationDuration ?? _defaultDuration; final bool isExpanded = expandedIndex != null; + final int totalBottomCount = widget.bottomConfigs.length; + + // 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 = [ + PanelSizeRepository.mainPanelKey, + for (int i = 0; i < totalBottomCount; i++) _bottomPanelKey(i), + ]; + + _syncPanelFractions( + allPanelKeys, + (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); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final int dividerCount = isExpanded ? 0 : totalBottomCount; + final double usableHeight = + _usableHeightFor(constraints.maxHeight, dividerCount); + + Widget panelSizedBox(double fraction, Widget child) => SizedBox( + height: fraction * usableHeight, + child: child, + ); - return Column( - children: [ - Expanded( - flex: 3, - child: 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, + 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, + ), ), - ), - if (bottomSeries?.isNotEmpty ?? false) - ...bottomSeries!.mapIndexed((int index, Series series) { - if (isExpanded && expandedIndex != index) { - return const SizedBox.shrink(); - } - - return Expanded( - flex: isExpanded ? bottomSeries.length : 1, - child: BottomChart( - series: series, + ]; + + for (int index = 0; index < totalBottomCount; index++) { + if (isExpanded && expandedIndex != index) { + continue; + } + + final String key = _bottomPanelKey(index); + + // 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( + ResizableChartDivider( + onDragUpdate: (double deltaPixels) => _resizeCascadingPanels( + allPanelKeys, + dividerIndex, + deltaPixels / 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, @@ -96,16 +155,19 @@ class _ChartStateWeb extends _ChartState { ), isExpanded: isExpanded, showCrosshair: widget.showCrosshair, - showExpandedIcon: bottomSeries.length > 1, + showExpandedIcon: totalBottomCount > 1, showMoveUpIcon: - !isExpanded && bottomSeries.length > 1 && index != 0, + !isExpanded && totalBottomCount > 1 && index != 0, showMoveDownIcon: !isExpanded && - bottomSeries.length > 1 && - index != bottomSeries.length - 1, + totalBottomCount > 1 && + index != totalBottomCount - 1, ), - ); - }).toList() - ], + ), + ); + } + + return Column(children: children); + }, ); } } diff --git a/lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart b/lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart new file mode 100644 index 000000000..9d83c0351 --- /dev/null +++ b/lib/src/deriv_chart/chart/panel_size/panel_size_repository.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Persists the relative heights (as fractions of the total available +/// height) of the chart's vertically stacked panels: [MainChart] and each +/// bottom indicator panel. +/// +/// Panels are identified by a string key: `'main'` for the main chart, and +/// `IndicatorConfig.configId` for each bottom indicator panel. +/// +/// Deliberately **not** scoped by symbol - unlike indicators or drawing +/// tools, a resized panel layout is a user preference about how they like +/// to view charts in general, not something tied to a particular market, so +/// it stays the same across symbol switches instead of being reloaded (or +/// reset) per symbol. +class PanelSizeRepository extends ChangeNotifier { + /// Key of the [MainChart] panel in [fractions]. + static const String mainPanelKey = 'main'; + + /// Storage key of the saved panel fractions. Not symbol-scoped - see the + /// class doc. + static const String _storageKey = 'panelHeights'; + + /// Current known fractions, keyed by panel key. + /// + /// Empty until [loadFromPrefs] has completed, or until [save] has been + /// called at least once. + Map fractions = {}; + + SharedPreferences? _prefs; + + int _loadGeneration = 0; + + /// Bumped every time [loadFromPrefs] completes (0 means it never has). + /// + /// `SharedPreferences` access is async, so a chart's very first build + /// always happens before this repo has finished loading - it seeds panels + /// with default fractions first, then needs to know when the load has + /// completed so it can re-apply the real saved fractions over those + /// defaults. A plain "have we ever loaded" flag isn't enough for that: it + /// also has to notice a *second* load completing (e.g. if a host app + /// calls [loadFromPrefs] again later), which is what a bumping generation + /// counter - rather than a one-shot bool - is for. + int get loadGeneration => _loadGeneration; + + /// Loads previously saved panel fractions from [prefs]. + void loadFromPrefs(SharedPreferences prefs) { + _prefs = prefs; + + final String? encoded = prefs.getString(_storageKey); + + if (encoded == null) { + fractions = {}; + _loadGeneration++; + notifyListeners(); + return; + } + + final Map decoded = + jsonDecode(encoded) as Map; + + fractions = decoded.map( + (String key, dynamic value) => MapEntry( + key, + (value as num).toDouble(), + ), + ); + + _loadGeneration++; + notifyListeners(); + } + + /// Saves [newFractions] and updates [fractions]. + Future save(Map newFractions) async { + fractions = Map.from(newFractions); + + if (_prefs != null) { + await _prefs!.setString(_storageKey, jsonEncode(fractions)); + } + + notifyListeners(); + } +} diff --git a/lib/src/deriv_chart/chart/resizable_chart_divider.dart b/lib/src/deriv_chart/chart/resizable_chart_divider.dart new file mode 100644 index 000000000..eeb5b7acf --- /dev/null +++ b/lib/src/deriv_chart/chart/resizable_chart_divider.dart @@ -0,0 +1,121 @@ +import 'package:deriv_chart/src/theme/chart_theme.dart'; +import 'package:deriv_chart/src/theme/dimens.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Width of the visible drag handle pill. +const double _handleWidth = 32; + +/// Height of the visible drag handle pill. +const double _handleHeight = 8; + +/// Height of the web hover/drag band around the line - narrower than the +/// touch hit area, since a mouse is precise enough that the cursor should +/// only change (and dragging only start) when it's close to the line and +/// handle, not anywhere within the divider's full row height. This only +/// affects the invisible hit-test region, not the visible line/handle. +const double _webDragBandHeight = 24; + +/// A horizontal divider placed between two vertically stacked chart panels +/// (e.g. between the main chart and a bottom indicator panel, or between two +/// bottom indicator panels). +/// +/// On web, a narrow band around the line is draggable and shows a resize +/// cursor on hover, since mouse pointers are precise. On touch devices, only +/// the drag handle at its horizontal center is draggable, so dragging +/// doesn't start from an accidental tap anywhere along the line. Either way, +/// the line and the handle switch to [ChartTheme.panelDividerActiveColor] +/// only while actively being dragged. +class ResizableChartDivider extends StatefulWidget { + /// Creates a [ResizableChartDivider]. + const ResizableChartDivider({ + required this.onDragUpdate, + this.onDragEnd, + Key? key, + }) : super(key: key); + + /// Called with the vertical drag delta, in logical pixels, as the user + /// drags the divider. + final ValueChanged onDragUpdate; + + /// Called when the drag gesture ends. + final VoidCallback? onDragEnd; + + @override + State createState() => _ResizableChartDividerState(); +} + +class _ResizableChartDividerState extends State { + bool _isDragging = false; + + ChartTheme get _theme => context.read(); + + Widget _dragArea({required Widget child}) => MouseRegion( + cursor: SystemMouseCursors.resizeRow, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (_) => setState(() => _isDragging = true), + onVerticalDragUpdate: (DragUpdateDetails details) => + widget.onDragUpdate(details.delta.dy), + onVerticalDragEnd: (_) { + setState(() => _isDragging = false); + widget.onDragEnd?.call(); + }, + onVerticalDragCancel: () => setState(() => _isDragging = false), + child: child, + ), + ); + + @override + Widget build(BuildContext context) { + final Color color = _isDragging + ? _theme.panelDividerActiveColor + : _theme.crosshairLineDesktopColor; + + final Widget handle = Container( + width: _handleWidth, + height: _handleHeight, + decoration: BoxDecoration( + color: _isDragging ? color : _theme.backgroundColor, + borderRadius: BorderRadius.circular(Dimens.borderRadius04), + border: Border.all(color: color), + ), + ); + + // The visible line and handle, at their natural size, unaffected by + // however small or large the interactive hit area below is. + final Widget visuals = Stack( + alignment: Alignment.center, + children: [ + Container(height: 1, color: color), + Center(child: handle), + ], + ); + + final Widget hitArea = kIsWeb + ? Center( + child: SizedBox( + height: _webDragBandHeight, + width: double.infinity, + child: _dragArea(child: const SizedBox.expand()), + ), + ) + : Center( + child: SizedBox( + width: Dimens.chartPanelDividerHandleHitWidth, + height: Dimens.chartPanelDividerHitHeight, + child: _dragArea(child: const SizedBox.expand()), + ), + ); + + return SizedBox( + height: Dimens.chartPanelDividerHitHeight, + width: double.infinity, + child: Stack( + alignment: Alignment.center, + children: [visuals, hitArea], + ), + ); + } +} diff --git a/lib/src/deriv_chart/deriv_chart.dart b/lib/src/deriv_chart/deriv_chart.dart index 433d8fb49..51523ae52 100644 --- a/lib/src/deriv_chart/deriv_chart.dart +++ b/lib/src/deriv_chart/deriv_chart.dart @@ -8,6 +8,7 @@ import 'package:deriv_chart/src/add_ons/extensions.dart'; import 'package:deriv_chart/src/add_ons/repository.dart'; import 'package:deriv_chart/src/deriv_chart/chart/chart.dart'; import 'package:deriv_chart/src/deriv_chart/chart/data_visualization/annotations/chart_annotation.dart'; +import 'package:deriv_chart/src/deriv_chart/chart/panel_size/panel_size_repository.dart'; 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'; @@ -54,6 +55,7 @@ class DerivChart extends StatefulWidget { this.chartAxisConfig = const ChartAxisConfig(), this.indicatorsRepo, this.drawingToolsRepo, + this.panelSizeRepo, this.drawingTools, this.msPerPx, this.minIntervalWidth, @@ -184,6 +186,10 @@ class DerivChart extends StatefulWidget { /// Chart's drawings final Repository? drawingToolsRepo; + /// Persists the relative sizes of the main chart and bottom indicator + /// panels as the user resizes them. + final PanelSizeRepository? panelSizeRepo; + /// Drawing tools final DrawingTools? drawingTools; @@ -212,6 +218,8 @@ class _DerivChartState extends State { late AddOnsRepository _drawingToolsRepo; + final PanelSizeRepository _panelSizeRepo = PanelSizeRepository(); + final DrawingTools _drawingTools = DrawingTools(); late final InteractiveLayerBehaviour _interactiveLayerBehaviour; @@ -266,10 +274,29 @@ class _DerivChartState extends State { loadSavedIndicatorsAndDrawingTools(); }); } + + if (widget.panelSizeRepo == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadPanelSizes(); + }); + } + } + + /// Loads saved panel sizes, if the host app hasn't supplied its own + /// [PanelSizeRepository]. + /// + /// Deliberately independent of [loadSavedIndicatorsAndDrawingTools] (and + /// its `drawingToolsRepo == null` gating) - a host app can supply its own + /// `indicatorsRepo`/`drawingToolsRepo` while still relying on the default + /// panel-size persistence, and that shouldn't disable panel-size loading. + Future _loadPanelSizes() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + _panelSizeRepo.loadFromPrefs(prefs); } Future loadSavedIndicatorsAndDrawingTools() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); + final List> _stateRepos = >[_indicatorsRepo, _drawingToolsRepo]; @@ -390,6 +417,7 @@ class _DerivChartState extends State { annotations: widget.annotations, showCrosshair: widget.showCrosshair, indicatorsRepo: widget.indicatorsRepo ?? _indicatorsRepo, + panelSizeRepo: widget.panelSizeRepo ?? _panelSizeRepo, msPerPx: widget.msPerPx, minIntervalWidth: widget.minIntervalWidth, maxIntervalWidth: widget.maxIntervalWidth, diff --git a/lib/src/theme/chart_default_dark_theme.dart b/lib/src/theme/chart_default_dark_theme.dart index 2ffc95268..35834675a 100644 --- a/lib/src/theme/chart_default_dark_theme.dart +++ b/lib/src/theme/chart_default_dark_theme.dart @@ -132,6 +132,9 @@ class ChartDefaultDarkTheme extends ChartDefaultTheme { Color get floatingMenuDragIconColor => DarkThemeColors.floatingMenuDragIconColor; + @override + Color get panelDividerActiveColor => DarkThemeColors.panelDividerActiveColor; + @override Color get lineThicknessDropdownButtonTextColor => DarkThemeColors.lineThicknessDropdownButtonTextColor; diff --git a/lib/src/theme/chart_default_light_theme.dart b/lib/src/theme/chart_default_light_theme.dart index 94930b4ef..07bf962b6 100644 --- a/lib/src/theme/chart_default_light_theme.dart +++ b/lib/src/theme/chart_default_light_theme.dart @@ -132,6 +132,9 @@ class ChartDefaultLightTheme extends ChartDefaultTheme { Color get floatingMenuDragIconColor => LightThemeColors.floatingMenuDragIconColor; + @override + Color get panelDividerActiveColor => LightThemeColors.panelDividerActiveColor; + @override Color get lineThicknessDropdownButtonTextColor => LightThemeColors.lineThicknessDropdownButtonTextColor; diff --git a/lib/src/theme/chart_theme.dart b/lib/src/theme/chart_theme.dart index 89af4df4b..31722c244 100644 --- a/lib/src/theme/chart_theme.dart +++ b/lib/src/theme/chart_theme.dart @@ -94,6 +94,10 @@ abstract class ChartTheme { Color get floatingMenuDragIconColor; + /// Color of a resizable chart panel divider's line and drag handle while + /// it's being dragged. + Color get panelDividerActiveColor; + Color get lineThicknessDropdownButtonTextColor; TextStyle get lineThicknessDropdownButtonTextStyle; diff --git a/lib/src/theme/colors.dart b/lib/src/theme/colors.dart index 4d38d5162..7749d7ac4 100644 --- a/lib/src/theme/colors.dart +++ b/lib/src/theme/colors.dart @@ -110,6 +110,9 @@ class LightThemeColors { static final Color floatingMenuDragIconColor = LightThemeDesignTokens .semanticColorMonochromeTextIconNormalMid; // Hex: #000000 with appropriate opacity + static const Color panelDividerActiveColor = LightThemeDesignTokens + .semanticColorBlueSolidSurfaceNormalHighest; // Hex: #2C9AFF + static const Color lineThicknessDropdownButtonTextColor = ComponentDesignTokens .componentTextIconNormalProminentLight; // Hex: #000000 @@ -245,6 +248,9 @@ class DarkThemeColors { static final Color floatingMenuDragIconColor = DarkThemeDesignTokens .semanticColorMonochromeTextIconNormalMid; // Hex: #000000 with appropriate opacity + static const Color panelDividerActiveColor = DarkThemeDesignTokens + .semanticColorBlueSolidSurfaceNormalHighest; // Hex: #2C9AFF + // Line thickness dropdown colors static const Color lineThicknessDropdownButtonTextColor = ComponentDesignTokens diff --git a/lib/src/theme/dimens.dart b/lib/src/theme/dimens.dart index c38ad2321..0d15d667e 100644 --- a/lib/src/theme/dimens.dart +++ b/lib/src/theme/dimens.dart @@ -48,4 +48,38 @@ class Dimens { /// Large area line thickness 2 static const double areaLineLargeThickness = 2; + + /// Minimum height a chart panel (main chart or a bottom indicator panel) + /// can be resized down to via [ResizableChartDivider], expressed as a + /// fraction of the total space shared by all panels in its chain rather + /// than a fixed pixel amount. A fixed pixel minimum ends up too large a + /// share of the screen on small devices (e.g. with the maximum of 3 + /// indicators on a short screen); a fixed fraction scales down with the + /// screen instead. + static const double minChartPanelHeightFraction = 0.1; + + /// Height of the hit area for [ResizableChartDivider]. Sized well above + /// its visible line so the drag target stays comfortably tappable on + /// touch devices. + static const double chartPanelDividerHitHeight = 40; + + /// Width of the touch hit area around [ResizableChartDivider]'s drag + /// handle on mobile - wider than the visible handle so it's easy to tap + /// and grab without needing pixel-perfect accuracy. + static const double chartPanelDividerHandleHitWidth = 64; + + /// Minimum height of a bottom indicator panel's title bar (its name, + /// hide/unhide, and up/down icons) - enforced regardless of the panel's + /// actual fraction-based height, whether it's hidden (showing only this + /// row, collapsed) or visible but dragged down toward + /// [minChartPanelHeightFraction]. + /// + /// Unlike a panel's chart content - which can render at any height - this + /// row is made up of fixed-size text and icons, so it can't be compressed + /// to fit whatever share of the screen its fraction happens to work out + /// to (e.g. several indicators added on a short screen before any of them + /// have been manually resized, or a visible panel's divider dragged all + /// 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; } diff --git a/test/deriv_chart/chart/panel_fractions_test.dart b/test/deriv_chart/chart/panel_fractions_test.dart new file mode 100644 index 000000000..b515a07a7 --- /dev/null +++ b/test/deriv_chart/chart/panel_fractions_test.dart @@ -0,0 +1,188 @@ +import 'package:deriv_chart/src/deriv_chart/chart/chart.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('resizeCascadingFractions', () { + // Matches Dimens.minChartPanelHeightFraction. + const double minFraction = 0.1; + + test('shrinks the immediate neighbor when it has room', () { + final Map fractions = { + 'a': 0.4, + 'b': 0.3, + 'c': 0.3, + }; + + final bool changed = resizeCascadingFractions( + fractions, + ['a', 'b', 'c'], + 1, // divider between b and c + -0.1, // grow c, shrink b + ); + + expect(changed, isTrue); + expect(fractions['a'], 0.4); + expect(fractions['b'], closeTo(0.2, 1e-9)); + expect(fractions['c'], closeTo(0.4, 1e-9)); + }); + + test( + 'cascades past a middle panel already at minimum height to a ' + 'further panel with room', () { + final Map fractions = { + 'a': 0.7, // has plenty of room + 'b': minFraction, // already at the minimum + 'c': 0.2, + }; + + final bool changed = resizeCascadingFractions( + fractions, + ['a', 'b', 'c'], + 1, // divider between b and c + -0.1, // try to grow c by 0.1, at b's expense + ); + + expect(changed, isTrue); + // b can't give anything up (already at the minimum), so the shrink + // cascades to a instead; b is unchanged, c gets the full delta. + expect(fractions['a'], closeTo(0.6, 1e-9)); + expect(fractions['b'], closeTo(minFraction, 1e-9)); + expect(fractions['c'], closeTo(0.3, 1e-9)); + }); + + test('clamps to the total room available across the whole donor chain', () { + final Map fractions = { + 'a': 0.15, // only 0.05 of room above the minimum + 'b': minFraction, // already at the minimum + 'c': 0.75, + }; + + final bool changed = resizeCascadingFractions( + fractions, + ['a', 'b', 'c'], + 1, // divider between b and c + -0.2, // ask for more than is available + ); + + expect(changed, isTrue); + expect(fractions['a'], closeTo(minFraction, 1e-9)); + expect(fractions['b'], closeTo(minFraction, 1e-9)); + expect(fractions['c'], closeTo(0.8, 1e-9)); + }); + + test('growing the panel above the divider cascades downward instead', () { + final Map fractions = { + 'a': 0.2, + 'b': minFraction, // already at the minimum + 'c': 0.7, + }; + + final bool changed = resizeCascadingFractions( + fractions, + ['a', 'b', 'c'], + 0, // divider between a and b + 0.1, // grow a, shrink b (then cascade to c) + ); + + expect(changed, isTrue); + expect(fractions['a'], closeTo(0.3, 1e-9)); + expect(fractions['b'], closeTo(minFraction, 1e-9)); + expect(fractions['c'], closeTo(0.6, 1e-9)); + }); + + test('returns false and makes no changes when nothing can be donated', () { + final Map fractions = { + 'a': minFraction, // at the minimum - nothing to donate + 'b': minFraction, // at the minimum - nothing to donate + 'c': 1 - 2 * minFraction, + }; + final Map before = Map.from(fractions); + + final bool changed = resizeCascadingFractions( + fractions, + ['a', 'b', 'c'], + 1, + -0.05, + ); + + expect(changed, isFalse); + expect(fractions, before); + }); + }); + + group('syncPanelFractions', () { + test('seeds new keys from defaultFraction when nothing is saved yet', () { + final Map fractions = {}; + + syncPanelFractions( + fractions, + ['main', 'a'], + const {}, // saved repo hasn't loaded yet + (String key) => key == 'main' ? 0.7 : 0.3, + ); + + expect(fractions['main'], 0.7); + expect(fractions['a'], 0.3); + }); + + test( + 'without forceApplySaved, an already-seeded key is never overwritten ' + 'by a saved value that arrives later', () { + final Map fractions = { + 'main': 0.7, + 'a': 0.3, + }; + + // Simulates the saved fractions finishing their async load only + // after this chart's first build already seeded the defaults above. + syncPanelFractions( + fractions, + ['main', 'a'], + const {'main': 0.4, 'a': 0.6}, + (String key) => key == 'main' ? 0.7 : 0.3, + ); + + expect(fractions['main'], 0.7); + expect(fractions['a'], 0.3); + }); + + test( + 'forceApplySaved overwrites already-seeded keys with the saved ' + 'value - the fix for the load-finishes-after-first-build race', () { + final Map fractions = { + 'main': 0.7, + 'a': 0.3, + }; + + syncPanelFractions( + fractions, + ['main', 'a'], + const {'main': 0.4, 'a': 0.6}, + (String key) => key == 'main' ? 0.7 : 0.3, + forceApplySaved: true, + ); + + expect(fractions['main'], 0.4); + expect(fractions['a'], 0.6); + }); + + test( + 'forceApplySaved leaves a key alone if the saved map has no entry ' + 'for it (e.g. a newly-added panel not yet in storage)', () { + final Map fractions = {'main': 0.5}; + + syncPanelFractions( + fractions, + ['main', 'a'], + const {'main': 0.9}, // no entry for 'a' + (String key) => key == 'main' ? 0.7 : 0.3, + forceApplySaved: true, + ); + + // main:a lands at 0.9:0.3 (a fell back to defaultFraction) before + // being renormalized to sum to 1.0, i.e. a 3:1 ratio. + expect(fractions['main'], closeTo(0.75, 1e-9)); + expect(fractions['a'], closeTo(0.25, 1e-9)); + }); + }); +} diff --git a/test/deriv_chart/chart/panel_size_repository_test.dart b/test/deriv_chart/chart/panel_size_repository_test.dart new file mode 100644 index 000000000..c01faa54b --- /dev/null +++ b/test/deriv_chart/chart/panel_size_repository_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:deriv_chart/src/deriv_chart/chart/panel_size/panel_size_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + const String storageKey = 'panelHeights'; + + group('PanelSizeRepository', () { + test('loadFromPrefs with no saved data leaves fractions empty', () async { + SharedPreferences.setMockInitialValues({}); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final PanelSizeRepository repo = PanelSizeRepository(); + repo.loadFromPrefs(prefs); + + expect(repo.fractions, isEmpty); + }); + + test('loadFromPrefs restores previously saved fractions', () async { + SharedPreferences.setMockInitialValues({ + storageKey: jsonEncode({ + PanelSizeRepository.mainPanelKey: 0.6, + 'indicator_1': 0.4, + }), + }); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final PanelSizeRepository repo = PanelSizeRepository(); + repo.loadFromPrefs(prefs); + + expect(repo.fractions[PanelSizeRepository.mainPanelKey], 0.6); + expect(repo.fractions['indicator_1'], 0.4); + }); + + test('save persists fractions and updates the in-memory value', () async { + SharedPreferences.setMockInitialValues({}); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final PanelSizeRepository repo = PanelSizeRepository(); + repo.loadFromPrefs(prefs); + + await repo.save({ + PanelSizeRepository.mainPanelKey: 0.7, + 'indicator_1': 0.3, + }); + + expect(repo.fractions[PanelSizeRepository.mainPanelKey], 0.7); + + // Round-trips through a fresh instance/prefs read. + final PanelSizeRepository reloaded = PanelSizeRepository(); + reloaded.loadFromPrefs(prefs); + + expect(reloaded.fractions[PanelSizeRepository.mainPanelKey], 0.7); + expect(reloaded.fractions['indicator_1'], 0.3); + }); + + test( + 'fractions are not scoped per symbol - the same saved sizes apply ' + 'regardless of which symbol/market is active', () async { + SharedPreferences.setMockInitialValues({ + storageKey: jsonEncode({'main': 0.6}), + }); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final PanelSizeRepository repo = PanelSizeRepository(); + repo.loadFromPrefs(prefs); + expect(repo.fractions['main'], 0.6); + + // Loading again (e.g. simulating a symbol switch) reads the exact + // same global entry, not a per-symbol one. + repo.loadFromPrefs(prefs); + expect(repo.fractions['main'], 0.6); + }); + + test('loadGeneration starts at 0 and bumps on every loadFromPrefs call', + () async { + SharedPreferences.setMockInitialValues({}); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final PanelSizeRepository repo = PanelSizeRepository(); + expect(repo.loadGeneration, 0); + + repo.loadFromPrefs(prefs); + expect(repo.loadGeneration, 1); + + // A later reload bumps it again, so a `Chart` that already applied + // generation 1 knows to re-apply. + repo.loadFromPrefs(prefs); + expect(repo.loadGeneration, 2); + }); + }); +} diff --git a/test/deriv_chart/chart/resizable_chart_divider_test.dart b/test/deriv_chart/chart/resizable_chart_divider_test.dart new file mode 100644 index 000000000..fc09b91b0 --- /dev/null +++ b/test/deriv_chart/chart/resizable_chart_divider_test.dart @@ -0,0 +1,64 @@ +import 'package:deriv_chart/src/deriv_chart/chart/resizable_chart_divider.dart'; +import 'package:deriv_chart/src/theme/chart_default_light_theme.dart'; +import 'package:deriv_chart/src/theme/chart_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + Widget wrap(Widget child) => MaterialApp( + home: Provider.value( + value: ChartDefaultLightTheme(), + child: Scaffold(body: child), + ), + ); + + testWidgets('reports vertical drag delta via onDragUpdate', + (WidgetTester tester) async { + final List deltas = []; + + await tester.pumpWidget( + wrap( + ResizableChartDivider( + onDragUpdate: deltas.add, + ), + ), + ); + + final Offset start = tester.getCenter(find.byType(ResizableChartDivider)); + final TestGesture gesture = await tester.startGesture(start); + await gesture.moveBy(const Offset(0, 20)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(deltas, isNotEmpty); + expect(deltas.reduce((double a, double b) => a + b), closeTo(20, 0.5)); + }); + + testWidgets('calls onDragEnd when the drag gesture finishes', + (WidgetTester tester) async { + bool dragEnded = false; + + await tester.pumpWidget( + wrap( + ResizableChartDivider( + onDragUpdate: (_) {}, + onDragEnd: () => dragEnded = true, + ), + ), + ); + + final Offset start = tester.getCenter(find.byType(ResizableChartDivider)); + final TestGesture gesture = await tester.startGesture(start); + await gesture.moveBy(const Offset(0, 10)); + await tester.pump(); + + expect(dragEnded, isFalse); + + await gesture.up(); + await tester.pump(); + + expect(dragEnded, isTrue); + }); +}