From 01aec1edbd8f02e21951918ec86f00fb28ef2c58 Mon Sep 17 00:00:00 2001 From: ramin-deriv <55975218+ramin-deriv@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:43:55 +0800 Subject: [PATCH] feat(deriv_mobile_chart_wrapper): [DERG-2498] create deriv_mobile_chart_wrapper package (#626) --- .github/workflows/all_packages.yaml | 4 +- .../deriv_mobile_chart_wrapper/.gitignore | 30 ++ packages/deriv_mobile_chart_wrapper/.metadata | 10 + .../deriv_mobile_chart_wrapper/CHANGELOG.md | 3 + packages/deriv_mobile_chart_wrapper/LICENSE | 1 + packages/deriv_mobile_chart_wrapper/README.md | 21 ++ .../analysis_options.yaml | 4 + .../assets/icons/ic_bollinger_bands.svg | 5 + .../assets/icons/ic_indicators_menu.svg | 3 + .../assets/icons/ic_macd.svg | 4 + .../assets/icons/ic_moving_average.svg | 3 + .../assets/icons/ic_rsi.svg | 5 + .../lib/deriv_mobile_chart_wrapper.dart | 6 + .../lib/src/assets.dart | 7 + .../lib/src/extensions.dart | 12 + .../lib/src/mobile_chart_wrapper.dart | 280 ++++++++++++++++++ .../mobile_tools_ui/chart_bottom_sheet.dart | 76 +++++ .../chart_setting_button_with_background.dart | 40 +++ .../custom_draggable_sheet.dart | 145 +++++++++ .../mobile_tools_ui/indicator_list_item.dart | 65 ++++ .../indicator_menu_button.dart | 29 ++ .../mobile_tools_bottom_sheet_content.dart | 79 +++++ .../src/mobile_tools_ui/tools_controller.dart | 21 ++ .../lib/src/models/indicator_item_model.dart | 14 + .../deriv_mobile_chart_wrapper/pubspec.yaml | 84 ++++++ .../test/mobile_chart_wrapper_test.dart | 77 +++++ 26 files changed, 1027 insertions(+), 1 deletion(-) create mode 100644 packages/deriv_mobile_chart_wrapper/.gitignore create mode 100644 packages/deriv_mobile_chart_wrapper/.metadata create mode 100644 packages/deriv_mobile_chart_wrapper/CHANGELOG.md create mode 100644 packages/deriv_mobile_chart_wrapper/LICENSE create mode 100644 packages/deriv_mobile_chart_wrapper/README.md create mode 100644 packages/deriv_mobile_chart_wrapper/analysis_options.yaml create mode 100644 packages/deriv_mobile_chart_wrapper/assets/icons/ic_bollinger_bands.svg create mode 100644 packages/deriv_mobile_chart_wrapper/assets/icons/ic_indicators_menu.svg create mode 100644 packages/deriv_mobile_chart_wrapper/assets/icons/ic_macd.svg create mode 100644 packages/deriv_mobile_chart_wrapper/assets/icons/ic_moving_average.svg create mode 100644 packages/deriv_mobile_chart_wrapper/assets/icons/ic_rsi.svg create mode 100644 packages/deriv_mobile_chart_wrapper/lib/deriv_mobile_chart_wrapper.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/assets.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/extensions.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_chart_wrapper.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_bottom_sheet.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_setting_button_with_background.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/custom_draggable_sheet.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_list_item.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_menu_button.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/mobile_tools_bottom_sheet_content.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/tools_controller.dart create mode 100644 packages/deriv_mobile_chart_wrapper/lib/src/models/indicator_item_model.dart create mode 100644 packages/deriv_mobile_chart_wrapper/pubspec.yaml create mode 100644 packages/deriv_mobile_chart_wrapper/test/mobile_chart_wrapper_test.dart diff --git a/.github/workflows/all_packages.yaml b/.github/workflows/all_packages.yaml index fb3c00d1d..038db27dd 100644 --- a/.github/workflows/all_packages.yaml +++ b/.github/workflows/all_packages.yaml @@ -30,7 +30,9 @@ jobs: - name: Set SSH Key uses: webfactory/ssh-agent@fd34b8dee206fe74b288a5e61bc95fba2f1911eb with: - ssh-private-key: ${{secrets.SSH_PRIVATE_KEY}} + ssh-private-key: | + ${{ secrets.SSH_PRIVATE_KEY }} + ${{ secrets.SSH_CHART_PRIVATE_KEY }} - name: Install Melos and run pub get uses: bluefireteam/melos-action@dd3c344d731938d2ab2567a261f54a19a68b5f6a diff --git a/packages/deriv_mobile_chart_wrapper/.gitignore b/packages/deriv_mobile_chart_wrapper/.gitignore new file mode 100644 index 000000000..96486fd93 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/deriv_mobile_chart_wrapper/.metadata b/packages/deriv_mobile_chart_wrapper/.metadata new file mode 100644 index 000000000..fa347fc6a --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: f468f3366c26a5092eb964a230ce7892fda8f2f8 + channel: stable + +project_type: package diff --git a/packages/deriv_mobile_chart_wrapper/CHANGELOG.md b/packages/deriv_mobile_chart_wrapper/CHANGELOG.md new file mode 100644 index 000000000..b4504b44c --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* Setup the package. diff --git a/packages/deriv_mobile_chart_wrapper/LICENSE b/packages/deriv_mobile_chart_wrapper/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/deriv_mobile_chart_wrapper/README.md b/packages/deriv_mobile_chart_wrapper/README.md new file mode 100644 index 000000000..da82d0a64 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/README.md @@ -0,0 +1,21 @@ +A wrapper package around package _**deriv_chart**_ to implement any functionality specific to mobile and can be wrapped around the main chart package. +Since the main [chart package](https://github.com/regentmarkets/flutter-chart) is used in both mobile app and the web platform, for any feature that is specific to mobile, to keep the size of the main chart package small, we implement it in this package. + +## Features +Menu and interfaces to add/remove indicators and drawing tools. + +## Dependencies +- [deriv_chart](https://github.com/regentmarkets/flutter-chart) +- [deriv_theme](https://github.com/regentmarkets/flutter-deriv-packages/tree/master/packages/deriv_theme) +- [deriv_localizations](https://github.com/regentmarkets/flutter-deriv-packages/tree/master/packages/deriv_localizations) +- [deriv_ui](https://github.com/regentmarkets/flutter-deriv-packages/tree/master/packages/deriv_ui) + +## Get started +``` +dependencies: + deriv_mobile_chart_wrapper: + git: + url: git@github.com:regentmarkets/flutter-deriv-packages.git + path: packages/deriv_mobile_chart_wrapper + ref: [latest_version] +``` diff --git a/packages/deriv_mobile_chart_wrapper/analysis_options.yaml b/packages/deriv_mobile_chart_wrapper/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/deriv_mobile_chart_wrapper/assets/icons/ic_bollinger_bands.svg b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_bollinger_bands.svg new file mode 100644 index 000000000..2abf85f5d --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_bollinger_bands.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/deriv_mobile_chart_wrapper/assets/icons/ic_indicators_menu.svg b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_indicators_menu.svg new file mode 100644 index 000000000..58ad8a51c --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_indicators_menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/deriv_mobile_chart_wrapper/assets/icons/ic_macd.svg b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_macd.svg new file mode 100644 index 000000000..b4da2a46a --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_macd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/deriv_mobile_chart_wrapper/assets/icons/ic_moving_average.svg b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_moving_average.svg new file mode 100644 index 000000000..f15853309 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_moving_average.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/deriv_mobile_chart_wrapper/assets/icons/ic_rsi.svg b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_rsi.svg new file mode 100644 index 000000000..b602ca188 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/assets/icons/ic_rsi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/deriv_mobile_chart_wrapper/lib/deriv_mobile_chart_wrapper.dart b/packages/deriv_mobile_chart_wrapper/lib/deriv_mobile_chart_wrapper.dart new file mode 100644 index 000000000..d9d336d92 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/deriv_mobile_chart_wrapper.dart @@ -0,0 +1,6 @@ +library deriv_mobile_chart_wrapper; + +export 'src/mobile_chart_wrapper.dart'; +export 'src/mobile_tools_ui/tools_controller.dart'; +export 'src/mobile_tools_ui/indicator_menu_button.dart'; +export 'package:deriv_chart/deriv_chart.dart'; diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/assets.dart b/packages/deriv_mobile_chart_wrapper/lib/src/assets.dart new file mode 100644 index 000000000..95253e2e1 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/assets.dart @@ -0,0 +1,7 @@ +const String iconAssetsFolder = 'assets/icons/'; + +const String macdIcon = '${iconAssetsFolder}ic_macd.svg'; +const String rsiIcon = '${iconAssetsFolder}ic_rsi.svg'; +const String bollingerBandsIcon = '${iconAssetsFolder}ic_bollinger_bands.svg'; +const String movingAverageIcon = '${iconAssetsFolder}ic_moving_average.svg'; +const String indicatorsMenuIcon = '${iconAssetsFolder}ic_indicators_menu.svg'; diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/extensions.dart b/packages/deriv_mobile_chart_wrapper/lib/src/extensions.dart new file mode 100644 index 000000000..35b539e12 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/extensions.dart @@ -0,0 +1,12 @@ +import 'package:deriv_localizations/l10n/generated/deriv_mobile_chart_wrapper/deriv_mobile_chart_wrapper_localizations.dart'; +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; + +/// Extension for [BuildContext]. +extension ContextExtension on BuildContext { + /// Get DerivMobileChartWrapperLocalizations. + DerivMobileChartWrapperLocalizations get mobileChartWrapperLocalizations => + DerivMobileChartWrapperLocalizations.of(this); + + ThemeProvider get themeProvider => DerivThemeProvider.getTheme(this); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_chart_wrapper.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_chart_wrapper.dart new file mode 100644 index 000000000..bdf01b22b --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_chart_wrapper.dart @@ -0,0 +1,280 @@ +import 'package:deriv_chart/deriv_chart.dart'; +import 'package:deriv_ui/components/components.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'mobile_tools_ui/chart_bottom_sheet.dart'; +import 'mobile_tools_ui/mobile_tools_bottom_sheet_content.dart'; +import 'mobile_tools_ui/tools_controller.dart'; + +/// The mobile version wrapper around the [Chart] which handles adding/removing +/// indicators and drawing tools to the chart. +class MobileChartWrapper extends StatefulWidget { + /// Initializes [MobileChartWrapper]. + const MobileChartWrapper({ + required this.mainSeries, + required this.granularity, + this.toolsStoreKey = 'default', + this.toolsController, + this.markerSeries, + this.controller, + this.onCrosshairAppeared, + this.onCrosshairDisappeared, + this.onCrosshairHover, + this.onVisibleAreaChanged, + this.onQuoteAreaChanged, + this.theme, + this.isLive = false, + this.dataFitEnabled = false, + this.showCrosshair = true, + this.annotations, + this.opacity = 1.0, + this.pipSize = 4, + this.chartAxisConfig = const ChartAxisConfig(), + this.maxCurrentTickOffset, + this.msPerPx, + this.minIntervalWidth, + this.maxIntervalWidth, + this.dataFitPadding, + this.currentTickAnimationDuration, + this.quoteBoundsAnimationDuration, + this.showCurrentTickBlinkAnimation, + this.verticalPaddingFraction, + this.bottomChartTitleMargin, + this.showDataFitButton, + this.showScrollToLastTickButton, + this.loadingAnimationColor, + Key? key, + }) : super(key: key); + + /// Chart's main data series + final DataSeries mainSeries; + + /// Open position marker series. + final MarkerSeries? markerSeries; + + /// The key which is used to store selected indicators/tools. + /// + /// When you pass the same key that was passed before when user selected some + /// tools, by passing the same key, the tools will be restored. + final String toolsStoreKey; + + /// Chart's controller + final ChartController? controller; + + /// Chart's tools controller. + final ToolsController? toolsController; + + /// Number of digits after decimal point in price. + final int pipSize; + + /// For candles: Duration of one candle in ms. + /// For ticks: Average ms difference between two consecutive ticks. + final int granularity; + + /// Called when crosshair details appear after long press. + final VoidCallback? onCrosshairAppeared; + + /// Called when the crosshair is dismissed. + final VoidCallback? onCrosshairDisappeared; + + /// Called when the crosshair cursor is hovered/moved. + final OnCrosshairHoverCallback? onCrosshairHover; + + /// Called when chart is scrolled or zoomed. + final VisibleAreaChangedCallback? onVisibleAreaChanged; + + /// Callback provided by library user. + final VisibleQuoteAreaChangedCallback? onQuoteAreaChanged; + + /// Chart's theme. + final ChartTheme? theme; + + /// Chart's annotations + final List>? annotations; + + /// Configurations for chart's axes. + final ChartAxisConfig chartAxisConfig; + + /// Whether the chart should be showing live data or not. + /// In case of being true the chart will keep auto-scrolling when its visible + /// area is on the newest ticks/candles. + final bool isLive; + + /// Starts in data fit mode and adds a data-fit button. + final bool dataFitEnabled; + + /// Chart's opacity, Will be applied on the [mainSeries]. + final double opacity; + + /// Whether the crosshair should be shown or not. + final bool showCrosshair; + + /// Max distance between rightBoundEpoch and nowEpoch in pixels. + final double? maxCurrentTickOffset; + + /// Specifies the zoom level of the chart. + final double? msPerPx; + + /// Specifies the minimum interval width + /// that is used for calculating the maximum msPerPx. + final double? minIntervalWidth; + + /// Specifies the maximum interval width + /// that is used for calculating the maximum msPerPx. + final double? maxIntervalWidth; + + /// Padding around data used in data-fit mode. + final EdgeInsets? dataFitPadding; + + /// Duration of the current tick animated transition. + final Duration? currentTickAnimationDuration; + + /// Duration of quote bounds animated transition. + final Duration? quoteBoundsAnimationDuration; + + /// Whether to show current tick blink animation or not. + final bool? showCurrentTickBlinkAnimation; + + /// Fraction of the chart's height taken by top or bottom padding. + /// Quote scaling (drag on quote area) is controlled by this variable. + final double? verticalPaddingFraction; + + /// Specifies the margin to prevent overlap. + final EdgeInsets? bottomChartTitleMargin; + + /// Whether the data fit button is shown or not. + final bool? showDataFitButton; + + /// Whether to show the scroll to last tick button or not. + final bool? showScrollToLastTickButton; + + /// The color of the loading animation. + final Color? loadingAnimationColor; + + @override + MobileChartWrapperState createState() => MobileChartWrapperState(); +} + +/// The state of the [MobileChartWrapper]. +class MobileChartWrapperState extends State { + AddOnsRepository? _indicatorsRepo; + + // TODO(Ramin): Add AddOnsRepository? and DrawingTools + // for drawing tools. + + @override + void initState() { + super.initState(); + + _initRepos(); + _setupController(); + } + + @override + void didUpdateWidget(covariant MobileChartWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.toolsStoreKey != oldWidget.toolsStoreKey) { + loadSavedIndicatorsAndDrawingTools(); + } + } + + void _setupController() { + widget.toolsController?.onShowIndicatorsToolsMenu = () { + if (_indicatorsRepo != null) { + _showIndicatorsSheet(_indicatorsRepo!); + } + }; + } + + void _initRepos() { + if (widget.toolsController?.indicatorsEnabled ?? false) { + _indicatorsRepo = AddOnsRepository( + createAddOn: (Map map) => + IndicatorConfig.fromJson(map), + onEditCallback: (_) => _showIndicatorsSheet(_indicatorsRepo!), + sharedPrefKey: widget.toolsStoreKey, + ); + } + + loadSavedIndicatorsAndDrawingTools(); + } + + Future loadSavedIndicatorsAndDrawingTools() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final List> stateRepos = + >[ + if (_indicatorsRepo != null) _indicatorsRepo!, + // TODO(Ramin): add drawing tools repo here. + ]; + + stateRepos + .asMap() + .forEach((int index, AddOnsRepository element) { + try { + element.loadFromPrefs(prefs, widget.toolsStoreKey); + } on Exception { + // ignore: unawaited_futures + showDialog( + context: context, + builder: (BuildContext context) => AnimatedPopupDialog( + child: Center( + child: element is Repository + // TODO(Ramin): use localization. + ? const Text('Failed loading indicators') + : const Text('Failed loading drawing tools'), + ), + ), + ); + } + }); + } + + void _showIndicatorsSheet(AddOnsRepository indicatorsRepo) { + // Show indicators menu as modal bottom sheet so it's dismissible by tapping + // outside. + showModalBottomSheet( + context: context, + builder: (_) => ChangeNotifierProvider>.value( + value: indicatorsRepo, + child: ChartBottomSheet( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: const MobileToolsBottomSheetContent(), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) => + // TODO(Ramin): Check if we can consider using Chart widget directly. + DerivChart( + indicatorsRepo: _indicatorsRepo ?? + AddOnsRepository( + createAddOn: (Map map) => + IndicatorConfig.fromJson(map), + sharedPrefKey: widget.toolsStoreKey, + ), + drawingToolsRepo: AddOnsRepository( + createAddOn: (Map map) => + DrawingToolConfig.fromJson(map), + sharedPrefKey: widget.toolsStoreKey, + ), + controller: widget.controller, + mainSeries: widget.mainSeries, + markerSeries: widget.markerSeries, + pipSize: widget.pipSize, + granularity: widget.granularity, + onVisibleAreaChanged: widget.onVisibleAreaChanged, + isLive: widget.isLive, + dataFitEnabled: widget.dataFitEnabled, + opacity: widget.opacity, + chartAxisConfig: widget.chartAxisConfig, + annotations: widget.annotations, + activeSymbol: widget.toolsStoreKey, + ); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_bottom_sheet.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_bottom_sheet.dart new file mode 100644 index 000000000..9cdb48942 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_bottom_sheet.dart @@ -0,0 +1,76 @@ +import 'package:deriv_chart/deriv_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'custom_draggable_sheet.dart'; + +/// Bottom sheet container used in chart library. +class ChartBottomSheet extends StatefulWidget { + /// Creates a bottom sheet container for [child]. + const ChartBottomSheet({ + required this.child, + this.theme, + Key? key, + }) : super(key: key); + + /// Body of bottom sheet container. + final Widget child; + + /// The theme of the chart which the bottom sheet is being placed inside. + final ChartTheme? theme; + + @override + _ChartBottomSheetState createState() => _ChartBottomSheetState(); +} + +class _ChartBottomSheetState extends State { + late ChartTheme _theme; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _theme = widget.theme ?? + (Theme.of(context).brightness == Brightness.dark + ? ChartDefaultDarkTheme() + : ChartDefaultLightTheme()); + } + + @override + Widget build(BuildContext context) => CustomDraggableSheet( + child: Provider.value( + value: _theme, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(_theme.borderRadius24Chart), + topRight: Radius.circular(_theme.borderRadius24Chart), + ), + child: Material( + elevation: 8, + color: _theme.base07Color, + child: Column( + children: [ + _buildTopHandle(), + Expanded(child: widget.child), + ], + ), + ), + ), + ), + ); + + Widget _buildTopHandle() => Container( + padding: EdgeInsets.symmetric(vertical: _theme.margin08Chart), + width: double.infinity, + child: Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: _theme.base05Color, + borderRadius: BorderRadius.circular(_theme.borderRadius04Chart), + ), + ), + ), + ); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_setting_button_with_background.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_setting_button_with_background.dart new file mode 100644 index 000000000..ae7afce7f --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/chart_setting_button_with_background.dart @@ -0,0 +1,40 @@ +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; + +/// A button with a background. +class ChartSettingButtonWithBackground extends StatelessWidget { + /// Creates a button with a background. + const ChartSettingButtonWithBackground({ + required this.child, + required this.onTap, + super.key, + }); + + /// The button content. + final Widget child; + + /// The callback function to be called when the button is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Container( + width: ThemeProvider.margin32, + height: ThemeProvider.margin32, + decoration: BoxDecoration( + color: context.theme.colors.secondary, + borderRadius: BorderRadius.circular(ThemeProvider.borderRadius04), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(ThemeProvider.borderRadius04), + child: InkWell( + borderRadius: BorderRadius.circular(ThemeProvider.borderRadius04), + child: Padding( + padding: const EdgeInsets.all(ThemeProvider.margin08), + child: child, + ), + onTap: onTap, + ), + ), + ); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/custom_draggable_sheet.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/custom_draggable_sheet.dart new file mode 100644 index 000000000..3128b3486 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/custom_draggable_sheet.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +/// A widget to manage the over-scroll to dismiss for a scrollable inside its +/// [child] that being shown by calling [showBottomSheet()]. +/// +/// This widget will listen to [OverscrollNotification] inside its [child] to +/// detect that it has reached its top scroll limit. when user is closing the +/// [child] by over-scrolling, it will call [Navigator.pop()], to fully dismiss +/// the [BottomSheet]. +class CustomDraggableSheet extends StatefulWidget { + /// Initializes a widget to manage the over-scroll to dismiss for a scrollable + /// inside its [child]. + const CustomDraggableSheet({ + required this.child, + Key? key, + this.animationDuration = const Duration(milliseconds: 100), + this.introAnimationDuration = const Duration(milliseconds: 300), + }) : super(key: key); + + /// The sheet that was popped-up inside a [BottomSheet] throw calling + /// [showBottomSheet]. + final Widget child; + + /// The duration of animation whether sheet will fling back to top or dismiss. + final Duration animationDuration; + + /// The duration of the starting animation. + final Duration introAnimationDuration; + + @override + _CustomDraggableSheetState createState() => _CustomDraggableSheetState(); +} + +class _CustomDraggableSheetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + final GlobalKey> _sheetKey = GlobalKey(); + + Size? _sheetSize; + + bool _overScrolled = false; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController.unbounded(vsync: this, value: 1) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed && + _animationController.value > 0.9) { + Navigator.of(context).pop(); + } + }); + + WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { + _sheetSize = _initSizes(); + _animationController.animateTo( + 0, + duration: widget.introAnimationDuration, + curve: Curves.easeOut, + ); + }); + } + + Size _initSizes() { + final RenderBox chartBox = + _sheetKey.currentContext!.findRenderObject() as RenderBox; + return chartBox.size; + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + key: _sheetKey, + animation: _animationController, + builder: (BuildContext context, Widget? child) => FractionalTranslation( + translation: Offset(0, _animationController.value), + child: child, + ), + child: NotificationListener( + onNotification: _handleScrollNotification, + child: widget.child, + ), + ); + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + bool _handleScrollNotification(Notification notification) { + if (_sheetSize != null && notification is OverscrollNotification) { + _overScrolled = true; + _panToBottom(notification); + } else if (notification is ScrollUpdateNotification && _overScrolled) { + _panToTop(notification); + } else if (!_animationController.isAnimating && + notification is ScrollEndNotification && + _overScrolled) { + _overScrolled = false; + _flingToTopOrBottom(); + } + + return false; + } + + void _panToTop(ScrollUpdateNotification notification) { + final double deltaPercent = notification.scrollDelta! / _sheetSize!.height; + + if (deltaPercent > 0) { + _updateSheetHeightBy(deltaPercent); + } + } + + void _panToBottom(OverscrollNotification notification) { + final double deltaPercent = notification.overscroll / _sheetSize!.height; + + if (deltaPercent < 0) { + _updateSheetHeightBy(deltaPercent); + } + } + + void _updateSheetHeightBy(double deltaPercent) { + _animationController.value -= deltaPercent; + final double value = _animationController.value.clamp(0.0, 1.0); + _animationController.value = value; + } + + void _flingToTopOrBottom() { + if (_animationController.value > 0.5) { + _animationController.animateTo( + 1, + duration: widget.animationDuration, + curve: Curves.easeOut, + ); + } else { + _animationController.animateTo( + 0, + duration: widget.animationDuration, + curve: Curves.easeOut, + ); + } + } +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_list_item.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_list_item.dart new file mode 100644 index 000000000..8bb00ec7f --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_list_item.dart @@ -0,0 +1,65 @@ +import 'package:deriv_mobile_chart_wrapper/src/extensions.dart'; +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// List item widget to show an indicator. +class IndicatorListItem extends StatelessWidget { + /// Constructor of the widget + const IndicatorListItem({ + required this.iconAssetPath, + required this.title, + required this.onInfoIconTapped, + this.count = 0, + super.key, + }); + + /// The path to the SVG icon asset. + final String iconAssetPath; + + /// The title of the indicator. + final String title; + + /// The callback which will be called when the info icon is tapped. + final VoidCallback onInfoIconTapped; + + /// Number of added indicators of this type. + /// + /// It will show in the item if it's greater than 0. + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(ThemeProvider.margin16), + child: Row( + children: [ + _buildIndicatorIcon(), + const SizedBox(width: Dimens.margin08), + _buildIndicatorTitle(context), + const Spacer(), + IconButton( + icon: const Icon(Icons.info_outline), + color: context.themeProvider.colors.prominent, + onPressed: onInfoIconTapped, + ), + ], + ), + ); + } + + Widget _buildIndicatorIcon() => SvgPicture.asset( + iconAssetPath, + width: ThemeProvider.margin24, + height: ThemeProvider.margin24, + package: 'deriv_mobile_chart_wrapper', + ); + + Widget _buildIndicatorTitle(BuildContext context) => Text( + title, + style: context.themeProvider.textStyle( + textStyle: TextStyles.body1, + color: context.themeProvider.colors.general, + ), + ); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_menu_button.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_menu_button.dart new file mode 100644 index 000000000..42c293168 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/indicator_menu_button.dart @@ -0,0 +1,29 @@ +import 'package:deriv_mobile_chart_wrapper/src/assets.dart'; +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import 'chart_setting_button_with_background.dart'; + +/// A button that opens the indicator menu. +class IndicatorMenuButton extends StatelessWidget { + /// Initializes the indicator menu button. + const IndicatorMenuButton({ + required this.onTap, + super.key, + }); + + /// The callback function to be called when the button is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => ChartSettingButtonWithBackground( + onTap: onTap, + child: SvgPicture.asset( + indicatorsMenuIcon, + width: ThemeProvider.margin18, + height: ThemeProvider.margin18, + package: 'deriv_mobile_chart_wrapper', + ), + ); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/mobile_tools_bottom_sheet_content.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/mobile_tools_bottom_sheet_content.dart new file mode 100644 index 000000000..cbbb7caa0 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/mobile_tools_bottom_sheet_content.dart @@ -0,0 +1,79 @@ +import 'package:deriv_mobile_chart_wrapper/src/assets.dart'; +import 'package:deriv_mobile_chart_wrapper/src/mobile_tools_ui/indicator_list_item.dart'; +import 'package:deriv_mobile_chart_wrapper/src/models/indicator_item_model.dart'; +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:deriv_mobile_chart_wrapper/src/extensions.dart'; +import 'package:flutter/material.dart'; + +/// Bottom sheet content to show the list of support tools (indicators/ drawing +/// tools) for the mobile version. +class MobileToolsBottomSheetContent extends StatelessWidget { + /// Initializes the bottom sheet content. + const MobileToolsBottomSheetContent({super.key}); + + static const List indicators = [ + IndicatorItemModel(title: 'MACD', icon: macdIcon), + IndicatorItemModel(title: 'Relative Strength Index (RSI)', icon: rsiIcon), + IndicatorItemModel(title: 'Bollinger Bands', icon: bollingerBandsIcon), + IndicatorItemModel(title: 'Moving Average', icon: movingAverageIcon), + ]; + + @override + Widget build(BuildContext context) => Column( + children: [ + _buildHeader(context), + Expanded( + child: Ink( + color: context.theme.colors.primary, + child: Column( + children: [ + const SizedBox(height: ThemeProvider.margin16), + _buildChipsList(), + Expanded(child: _buildIndicatorsList()), + ], + ), + ), + ), + ], + ); + + Widget _buildIndicatorsList() { + return ListView.builder( + itemCount: indicators.length, + itemBuilder: (_, index) { + final IndicatorItemModel indicator = indicators[index]; + + return IndicatorListItem( + iconAssetPath: indicator.icon, + title: indicator.title, + onInfoIconTapped: () {}, + ); + }, + ); + } + + Widget _buildChipsList() { + // Overscroll behaviour of horizontal chips list sometimes triggers + // BottomSheet top <-> bottom dragging. That's why we're capturing the + // overscroll here so it doesn't propagate up to the BottomSheet. + return NotificationListener( + onNotification: (OverscrollNotification notification) { + return true; + }, + // TODO(Ramin): add chips list. + child: const SizedBox.shrink(), + ); + } + + Widget _buildHeader(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(vertical: Dimens.margin16), + alignment: Alignment.center, + child: Text( + context.mobileChartWrapperLocalizations.labelIndicators, + style: DerivThemeProvider.getTheme(context).textStyle( + textStyle: TextStyles.subheading, + color: DerivThemeProvider.getTheme(context).colors.prominent, + ), + ), + ); +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/tools_controller.dart b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/tools_controller.dart new file mode 100644 index 000000000..a207eca9c --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/mobile_tools_ui/tools_controller.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +/// Controller class to show tools menu. +class ToolsController { + /// Initializes the tools controller. + ToolsController({this.indicatorsEnabled = true}); + + /// Whether indicators are enabled or not. + final bool indicatorsEnabled; + + /// Called to show indicators tools menu. + VoidCallback? onShowIndicatorsToolsMenu; + + /// Shows indicators tools menu. + void showIndicatorsToolsMenu() => onShowIndicatorsToolsMenu?.call(); + + /// Shows drawing tools menu. + void showDrawingToolsMenu() { + // TODO(Ramin): Call the callback for drawing tools. + } +} diff --git a/packages/deriv_mobile_chart_wrapper/lib/src/models/indicator_item_model.dart b/packages/deriv_mobile_chart_wrapper/lib/src/models/indicator_item_model.dart new file mode 100644 index 000000000..fc155c903 --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/lib/src/models/indicator_item_model.dart @@ -0,0 +1,14 @@ +/// Model class to keep the information of an indicator item. +class IndicatorItemModel { + /// Initializes an indicator item model. + const IndicatorItemModel({ + required this.title, + required this.icon, + }); + + /// The title. + final String title; + + /// The path to the SVG icon. + final String icon; +} diff --git a/packages/deriv_mobile_chart_wrapper/pubspec.yaml b/packages/deriv_mobile_chart_wrapper/pubspec.yaml new file mode 100644 index 000000000..a162e8bfe --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/pubspec.yaml @@ -0,0 +1,84 @@ +name: deriv_mobile_chart_wrapper +description: A new Flutter package project. +version: 0.0.1 +homepage: + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + deriv_chart: + git: + url: git@github.com:regentmarkets/flutter-chart.git + ref: dev + + deriv_theme: + git: + url: git@github.com:regentmarkets/flutter-deriv-packages.git + path: packages/deriv_theme + ref: deriv_theme-v2.5.0 + + deriv_localizations: + git: + url: git@github.com:regentmarkets/flutter-deriv-packages.git + path: packages/deriv_localizations + ref: deriv_localizations-v1.5.1 + + deriv_ui: + git: + url: git@github.com:regentmarkets/flutter-deriv-packages.git + path: packages/deriv_ui + ref: deriv_ui-v0.0.7+6 + + provider: ^6.0.5 + flutter_svg: ^2.0.9 + shared_preferences: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + mockito: ^5.4.2 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + uses-material-design: true + + assets: + - assets/icons/ic_macd.svg + - assets/icons/ic_rsi.svg + - assets/icons/ic_bollinger_bands.svg + - assets/icons/ic_moving_average.svg + - assets/icons/ic_indicators_menu.svg +# +# For details regarding assets in packages, see +# https://flutter.dev/assets-and-images/#from-packages +# +# An image asset can refer to one or more resolution-specific "variants", see +# https://flutter.dev/assets-and-images/#resolution-aware + +# To add custom fonts to your package, add a fonts section here, +# in this "flutter" section. Each entry in this list should have a +# "family" key with the font family name, and a "fonts" key with a +# list giving the asset and other descriptors for the font. For +# example: +# fonts: +# - family: Schyler +# fonts: +# - asset: fonts/Schyler-Regular.ttf +# - asset: fonts/Schyler-Italic.ttf +# style: italic +# - family: Trajan Pro +# fonts: +# - asset: fonts/TrajanPro.ttf +# - asset: fonts/TrajanPro_Bold.ttf +# weight: 700 +# +# For details regarding fonts in packages, see +# https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/deriv_mobile_chart_wrapper/test/mobile_chart_wrapper_test.dart b/packages/deriv_mobile_chart_wrapper/test/mobile_chart_wrapper_test.dart new file mode 100644 index 000000000..3fc7efb0b --- /dev/null +++ b/packages/deriv_mobile_chart_wrapper/test/mobile_chart_wrapper_test.dart @@ -0,0 +1,77 @@ +import 'package:deriv_localizations/l10n/generated/deriv_mobile_chart_wrapper/deriv_mobile_chart_wrapper_localizations.dart'; +import 'package:deriv_mobile_chart_wrapper/src/mobile_tools_ui/mobile_tools_bottom_sheet_content.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter/material.dart'; +import 'package:deriv_mobile_chart_wrapper/deriv_mobile_chart_wrapper.dart'; + +class MockToolsController extends Mock implements ToolsController { + @override + bool get indicatorsEnabled => true; +} + +class MockAddOnsRepository extends Mock + implements AddOnsRepository {} + +void main() { + group('MobileChartWrapper Tests', () { + testWidgets('MobileChartWrapper initializes correctly', + (WidgetTester tester) async { + await tester.pumpWidget(_TestWidget(toolsController: ToolsController())); + + // Verify initial state + expect(find.byType(DerivChart), findsOneWidget); + }); + + testWidgets('ToolsController showIndicatorsToolsMenu callback is set', + (WidgetTester tester) async { + final mockToolsController = MockToolsController(); + await tester.pumpWidget( + _TestWidget(toolsController: mockToolsController), + ); + + // Verify callback is set + verify(mockToolsController.onShowIndicatorsToolsMenu = any).called(1); + }); + + testWidgets('MobileChartWrapper shows indicators sheet', + (WidgetTester tester) async { + final toolsController = ToolsController(); + + await tester.pumpWidget( + _TestWidget( + toolsController: toolsController, + ), + ); + + // Trigger the callback to show the sheet + toolsController.showIndicatorsToolsMenu(); + await tester.pump(); + + // Verify the bottom sheet is displayed + expect(find.byType(MobileToolsBottomSheetContent), findsOneWidget); + }); + }); +} + +class _TestWidget extends StatelessWidget { + const _TestWidget({required this.toolsController}); + + final ToolsController toolsController; + + @override + Widget build(BuildContext context) { + return MaterialApp( + localizationsDelegates: const >[ + DerivMobileChartWrapperLocalizations.delegate, + ], + home: Material( + child: MobileChartWrapper( + mainSeries: LineSeries([]), + granularity: 60, + toolsController: toolsController, + ), + ), + ); + } +}