From 874e9779d48fc977c79f1720f8090a7f62a43975 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:26:23 +0800 Subject: [PATCH] feat: add numpad to deriv_ui --- packages/deriv_ui/README.md | 1 + .../assets/icons/ic_currency_swap.svg | 10 + packages/deriv_ui/assets/icons/ic_handle.svg | 3 + .../example/assets/icons/ic_currency_swap.svg | 10 + .../example/assets/icons/ic_handle.svg | 3 + .../deriv_ui/example/lib/numpad/main.dart | 150 ++++ packages/deriv_ui/example/pubspec.yaml | 2 + .../deriv_ui/lib/components/components.dart | 1 + .../deriv_ui/lib/components/numpad/README.md | 64 ++ .../lib/components/numpad/core/assets.dart | 7 + .../lib/components/numpad/core/core.dart | 5 + .../lib/components/numpad/core/enums.dart | 29 + .../numpad/core/exchange_notifier.dart | 128 ++++ .../core/extensions/context_extension.dart | 19 + .../numpad/core/extensions/extensions.dart | 1 + .../helpers/custom_website_status_helper.dart | 5 + .../numpad/core/helpers/helpers.dart | 2 + .../numpad/core/helpers/number_helper.dart | 100 +++ .../core/helpers/popup_dialogs_helper.dart | 41 ++ .../core/helpers/website_status_helper.dart | 5 + .../core/widgets/currency_switcher.dart | 74 ++ .../numpad/core/widgets/widgets.dart | 1 + .../numpad/models/currency_detail.dart | 35 + .../numpad/models/exchange_rate_model.dart | 18 + .../lib/components/numpad/models/models.dart | 4 + .../numpad/models/number_pad_data.dart | 11 + .../numpad/models/number_pad_label.dart | 63 ++ .../lib/components/numpad/numpad.dart | 3 + .../components/numpad/widgets/number_pad.dart | 693 ++++++++++++++++++ .../widgets/number_pad_animated_message.dart | 52 ++ .../widgets/number_pad_double_textfields.dart | 70 ++ .../numpad/widgets/number_pad_key_item.dart | 138 ++++ .../numpad/widgets/number_pad_keypad.dart | 57 ++ .../numpad/widgets/number_pad_message.dart | 44 ++ .../numpad/widgets/number_pad_provider.dart | 54 ++ .../widgets/number_pad_single_text_title.dart | 53 ++ .../widgets/number_pad_single_textfield.dart | 104 +++ .../numpad/widgets/number_pad_text_field.dart | 102 +++ .../components/numpad/widgets/widgets.dart | 1 + packages/deriv_ui/pubspec.yaml | 4 + .../numpad/widgets/number_pad_test.dart | 658 +++++++++++++++++ 41 files changed, 2825 insertions(+) create mode 100644 packages/deriv_ui/assets/icons/ic_currency_swap.svg create mode 100644 packages/deriv_ui/assets/icons/ic_handle.svg create mode 100644 packages/deriv_ui/example/assets/icons/ic_currency_swap.svg create mode 100644 packages/deriv_ui/example/assets/icons/ic_handle.svg create mode 100644 packages/deriv_ui/example/lib/numpad/main.dart create mode 100644 packages/deriv_ui/lib/components/numpad/README.md create mode 100644 packages/deriv_ui/lib/components/numpad/core/assets.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/core.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/enums.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/exchange_notifier.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/extensions/context_extension.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/extensions/extensions.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/helpers/custom_website_status_helper.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/helpers/helpers.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/helpers/number_helper.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/helpers/popup_dialogs_helper.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/helpers/website_status_helper.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/widgets/currency_switcher.dart create mode 100644 packages/deriv_ui/lib/components/numpad/core/widgets/widgets.dart create mode 100644 packages/deriv_ui/lib/components/numpad/models/currency_detail.dart create mode 100644 packages/deriv_ui/lib/components/numpad/models/exchange_rate_model.dart create mode 100644 packages/deriv_ui/lib/components/numpad/models/models.dart create mode 100644 packages/deriv_ui/lib/components/numpad/models/number_pad_data.dart create mode 100644 packages/deriv_ui/lib/components/numpad/models/number_pad_label.dart create mode 100644 packages/deriv_ui/lib/components/numpad/numpad.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_animated_message.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_double_textfields.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_key_item.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_keypad.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_message.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_provider.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_text_title.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_textfield.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/number_pad_text_field.dart create mode 100644 packages/deriv_ui/lib/components/numpad/widgets/widgets.dart create mode 100644 packages/deriv_ui/test/components/numpad/widgets/number_pad_test.dart diff --git a/packages/deriv_ui/README.md b/packages/deriv_ui/README.md index 918bd73af..f0c1ca789 100644 --- a/packages/deriv_ui/README.md +++ b/packages/deriv_ui/README.md @@ -13,6 +13,7 @@ Here is the detailed structure of the components included in this package: - [Banner](./lib/components/banner/README.md) - [Date Range Picker](./lib/components/date_range_picker/README.md) - [Expandable bottom sheet](./lib/components/expandable_bottom_sheet/README.md) +- [Numpad](./lib/components/numpad/README.md) ## Widgets diff --git a/packages/deriv_ui/assets/icons/ic_currency_swap.svg b/packages/deriv_ui/assets/icons/ic_currency_swap.svg new file mode 100644 index 000000000..2433cd25e --- /dev/null +++ b/packages/deriv_ui/assets/icons/ic_currency_swap.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/deriv_ui/assets/icons/ic_handle.svg b/packages/deriv_ui/assets/icons/ic_handle.svg new file mode 100644 index 000000000..6707c154c --- /dev/null +++ b/packages/deriv_ui/assets/icons/ic_handle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/deriv_ui/example/assets/icons/ic_currency_swap.svg b/packages/deriv_ui/example/assets/icons/ic_currency_swap.svg new file mode 100644 index 000000000..2433cd25e --- /dev/null +++ b/packages/deriv_ui/example/assets/icons/ic_currency_swap.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/deriv_ui/example/assets/icons/ic_handle.svg b/packages/deriv_ui/example/assets/icons/ic_handle.svg new file mode 100644 index 000000000..6707c154c --- /dev/null +++ b/packages/deriv_ui/example/assets/icons/ic_handle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/deriv_ui/example/lib/numpad/main.dart b/packages/deriv_ui/example/lib/numpad/main.dart new file mode 100644 index 000000000..62271f1bb --- /dev/null +++ b/packages/deriv_ui/example/lib/numpad/main.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:deriv_ui/deriv_ui.dart'; +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const Homepage(), + ); + } +} + +class Homepage extends StatelessWidget { + const Homepage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('DerivNumberPad Example'), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + final controller = + StreamController.broadcast(); + + final Timer timer = + Timer.periodic(const Duration(seconds: 5), (Timer timer) { + controller.add( + ExchangeRateModel( + baseCurrency: 'BTC', + targetCurrency: 'USD', + exchangeRate: (23 * timer.tick).toDouble(), + ), + ); + }); + + showModalBottomSheet( + isScrollControlled: true, + context: context, + backgroundColor: Colors.transparent, + builder: (BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NumberPad.withCurrencyExchanger( + onClose: (type, closeType, result) {}, + title: 'Amount', + exchangeRatesStream: controller.stream, + initialExchangeRate: ExchangeRateModel( + baseCurrency: 'BTC', + targetCurrency: 'USD', + exchangeRate: 42800, + ), + primaryCurrency: CurrencyDetail(0.123, 'BTC'), + label: NumberPadLabel( + onValidate: (value) { + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: + 'The daily limit between your USD Wallet and Deriv Apps is [100,000.00] USD', + style: context.theme.textStyle( + textStyle: TextStyles.captionBold, + color: context.theme.colors.disabled, + ), + ), + ], + ), + ); + }, + actionOK: 'OK', + ), + ), + ], + ), + ); + }, + child: const Text('Numpad with currency exchange'), + ), + ElevatedButton( + onPressed: () { + showModalBottomSheet( + isScrollControlled: true, + context: context, + backgroundColor: Colors.transparent, + builder: (BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + currency: 'USD', + firstInputTitle: 'Amount', + formatter: NumberFormat.decimalPattern(), + firstInputMinimumValue: 10, + firstInputMaximumValue: 60, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: (Object input, Object minValue, + Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, + Object minValue, + Object currency, + Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ], + ), + ); + }, + child: const Text('Numpad'), + ), + ], + ), + ); + } +} diff --git a/packages/deriv_ui/example/pubspec.yaml b/packages/deriv_ui/example/pubspec.yaml index 4ebb70a83..bbc95d0d9 100644 --- a/packages/deriv_ui/example/pubspec.yaml +++ b/packages/deriv_ui/example/pubspec.yaml @@ -64,6 +64,8 @@ flutter: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - assets/icons/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/packages/deriv_ui/lib/components/components.dart b/packages/deriv_ui/lib/components/components.dart index d4b065572..89b5905c5 100644 --- a/packages/deriv_ui/lib/components/components.dart +++ b/packages/deriv_ui/lib/components/components.dart @@ -1,3 +1,4 @@ export 'banner/banner.dart'; export 'date_range_picker/date_range_picker.dart'; export 'expandable_bottom_sheet/expandable_bottom_sheet.dart'; +export 'numpad/numpad.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/README.md b/packages/deriv_ui/lib/components/numpad/README.md new file mode 100644 index 000000000..d3507bd21 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/README.md @@ -0,0 +1,64 @@ +# Numberpad + +Number Pad Widget for number input in Deriv Go and Deriv P2P. + +## Usage: + +The `Numberpad` widget takes quite a few arguments, which are: + +- `formatter` (required): A `NumberFormat` that is fetched from the `intl` package. Sets the format of the input text. +- `numberpadType` (required): Either `NumberPadWidgetType.singleInput` for a single text field, or `NumberPadWidgetType.doubleInput` for two text fields. +- `label` (required): `NumberPadLabel` is a class that contains a list of necessary strings basid on current localizaiton: + + - `semanticNumberPadBottomSheetHandle`: Description for the `semanticsLabel` property of the handle SVG at the top. + - `actionOK`: The text on the `Ok` button in any open dialoge. + - `warnValueCantBeLessThan`: Warning when the entered value is less that minimum amount. + - `warnValueCantBeGreaterThan`: Warning when the entered value is greater that minimum amount. + - `warnDoubleInputValueCantBeLessThan`: Warning when input is invalid because it can't be less than minimum amount. + - `warnDoubleInputValueCantBeGreaterThan`: Warning when input is invalid because it can't be greater than minimum amount. + - `warnValueShouldBeInRange`: Warning when value is not in valid range. + +- `currency`: Sets the currency of the number pad. +- `firstInputTite`: Title of the first textfield. +- `secondInputTitle`: Title of the second textfield. (If input type is doubleInput) +- `manInputLength`: Maximum possible input characters for input values. +- `firstInputInitialValue`: The initial value of first text field. +- `secondInputInitialValue`: The initial value of second text field. +- `firstInputMinimumValue`: The minimum value in the first text field. +- `secondInputMinimumValue`: The minimum value in the second text field. +- `firstInputMaximumValue`: The maximum value int the first text field. +- `secondInputMaximumValue`: The maximum value int the second text field. +- `onOpen`: VoidCallback whem NumberPad opens. +- `onClose`: Callback when NumberPad closes. Takes 3 arguments (NumberadWidgetType, NumberPadCloseType, NumberPadData). + - `NumberPadWidgetType`: Current `NumberPadWidgetType`. + - `NumberPadCloseType`: `NumberPadCloseType.pressOK` for when the user clicks on the OK button to close numberPad widget. Or `NumberPadCloseType.clickOutsideView` for when the user clicks anywhere outside the widget to dismiss the NumberPad widget. + - `NumberPadData`: A wrapper around the values in the first and second input text fields. +- `currentFocus`: When there are two text fields, you can choose which one has initial focus, either `NumberPadInputFocus.firstInputField` for a the first text field, or `NumberPadInputFocus.secondInputField` for the second text field. +- `dialogeDescription`: The description text displayed when the info icon is clicked. +- `headerLeading`: The leading widget on the header of the Numberpad. + +## Example + +```dart +NumberPad( + formatter: NumberFormat.decimalPattern(), + numberPadType: NumberPadWidgetType.singleInput, + firstInputTitle: context.localization.labelTakeProfit, + firstInputInitialValue: initialTPValue, + secondInputTitle: context.localization.labelStopLoss, + secondInputInitialValue: initialSLValue, + currency: currency, + onClose: ( + NumberPadWidgetType type, + NumberPadCloseType closeType, + NumberPadData result, + ) async { + if (closeType == NumberPadCloseType.pressOK) { + widget.onTakeProfitStopLossChanged?.call( + result.firstInputValue ?? -1, + result.secondInputValue ?? -1, + ); + } + }, +), +``` diff --git a/packages/deriv_ui/lib/components/numpad/core/assets.dart b/packages/deriv_ui/lib/components/numpad/core/assets.dart new file mode 100644 index 000000000..eb9bd621b --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/assets.dart @@ -0,0 +1,7 @@ +// ignore_for_file: public_member_api_docs + +// This file contains all assets to have a single source of all resources + +// Icons +const String handleIcon = 'assets/icons/ic_handle.svg'; +const String swapIcon = 'assets/icons/ic_currency_swap.svg'; diff --git a/packages/deriv_ui/lib/components/numpad/core/core.dart b/packages/deriv_ui/lib/components/numpad/core/core.dart new file mode 100644 index 000000000..119a9694c --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/core.dart @@ -0,0 +1,5 @@ +export 'helpers/helpers.dart'; +export 'widgets/widgets.dart'; +export 'assets.dart'; +export 'enums.dart'; +export 'exchange_notifier.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/core/enums.dart b/packages/deriv_ui/lib/components/numpad/core/enums.dart new file mode 100644 index 000000000..769000367 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/enums.dart @@ -0,0 +1,29 @@ + + +/// Indicates the number of inputs in the NumberPad widget, between one or two inputs. +enum NumberPadWidgetType { + /// Has one input with a title at the top of the view. + singleInput, + + /// Has two inputs with a title at the top of each input. + doubleInput, +} + +/// Indicates which input field should be focused when [NumberPadWidgetType] is double input. +enum NumberPadInputFocus { + /// Focuses on the first input field. + firstInputField, + + /// Focuses on the second input field. + secondInputField, +} + +/// Indicates how NumberPad widget is closed +enum NumberPadCloseType { + /// When the user clicked on the OK button to close numberPad widget + pressOK, + + /// when the user clicked anywhere outside the widget to dismiss the NumberPad widget + clickOutsideView, +} + diff --git a/packages/deriv_ui/lib/components/numpad/core/exchange_notifier.dart b/packages/deriv_ui/lib/components/numpad/core/exchange_notifier.dart new file mode 100644 index 000000000..75f705c8a --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/exchange_notifier.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import '../models/models.dart'; + +/// This class is used to provide [ExchangeController] to the widget tree. +class ExchangeNotifier extends InheritedNotifier { + /// This class is used to provide [ExchangeController] to the widget tree. + const ExchangeNotifier({ + required Widget child, + ExchangeController? notifier, + }) : super(child: child, notifier: notifier); + + /// Retrieve [ExchangeController] from the widget tree. + static ExchangeController? of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.notifier; +} + +/// This class contains the all the logic of exchange. +class ExchangeController extends ChangeNotifier { + /// It controls exchange rate conversion, switching between currencies and all the logic related to currency. + ExchangeController({ + required Stream rateSource, + required CurrencyDetail primaryCurrency, + required this.currencyFieldController, + required ExchangeRateModel initialExchangeRate, + }) : _rateSource = rateSource { + _exchangeRate = initialExchangeRate; + _primaryCurrency = primaryCurrency; + _secondaryCurrency = CurrencyDetail( + _getExchangedOutput(_primaryCurrency.amount), + _exchangeRate.targetCurrency, + ); + _listenForExchangeRateChange(); + } + + /// controller for active textfield. + TextEditingController currencyFieldController; + late bool _isSwapped = false; + late ExchangeRateModel _exchangeRate; + late CurrencyDetail _primaryCurrency; + late CurrencyDetail _secondaryCurrency; + final Stream _rateSource; + + /// Currently active currency in textField + CurrencyDetail get primaryCurrency => _primaryCurrency; + + /// Exchanged currency + CurrencyDetail get secondaryCurrency => _secondaryCurrency; + + Future _listenForExchangeRateChange() async { + _rateSource.listen((ExchangeRateModel rate) { + _exchangeRate = rate; + if (currencyFieldController.text.isEmpty) { + _secondaryCurrency = CurrencyDetail(0, secondaryCurrency.currencyType); + } else { + _secondaryCurrency = CurrencyDetail( + _getExchangedOutput(_primaryCurrency.amount), + secondaryCurrency.currencyType); + } + notifyListeners(); + }); + } + + /// This is called when an amount is changed in textField and immediately converts amount in secondary currency. + void onChangeCurrency(String newValue) { + if (currencyFieldController.text.isNotEmpty) { + _secondaryCurrency = _getUpdatedSecondaryCurrency(); + _primaryCurrency = _getUpdatedPrimaryCurrency(); + notifyListeners(); + } else { + _secondaryCurrency = CurrencyDetail(0, _secondaryCurrency.currencyType); + } + } + + /// This will interchange the currency, amount from textField to switcher and vice-versa. + void swap() { + _isSwapped = !_isSwapped; + + final double localPrimary = + double.tryParse(currencyFieldController.text) ?? 0.0; + + final String localPrimaryCurrency = _primaryCurrency.currencyType; + + _primaryCurrency = CurrencyDetail( + _secondaryCurrency.amount, + _secondaryCurrency.currencyType, + ); + _secondaryCurrency = CurrencyDetail(localPrimary, localPrimaryCurrency); + currencyFieldController + ..text = _primaryCurrency.displayAmount + ..selection = TextSelection.fromPosition( + TextPosition(offset: currencyFieldController.text.length), + ); + notifyListeners(); + } + + CurrencyDetail _getUpdatedSecondaryCurrency() { + final double value = double.parse( + _getExchangedOutput(double.parse(currencyFieldController.text)) + .toStringAsFixed(8)); + return CurrencyDetail(value, _secondaryCurrency.currencyType); + } + + CurrencyDetail _getUpdatedPrimaryCurrency() => CurrencyDetail( + double.parse(currencyFieldController.text), + _primaryCurrency.currencyType); + + double _getExchangedOutput(double amount) => _isSwapped + ? _exchangeRate.getInverseOfExchangeRate() * amount + : _exchangeRate.exchangeRate * amount; + + /// This will return the final output currency. + /// + /// The output amount is the same as the base of exchange rate(as this is + /// what user send by default in primaryCurrency when coming to numpad.) + double finalAmount() { + if (_exchangeRate.baseCurrency == primaryCurrency.currencyType) { + return primaryCurrency.amount; + } else { + return secondaryCurrency.amount; + } + } +} + +/// Inversion extension. +extension ExchangeRateInverse on ExchangeRateModel { + /// This will get the inverse of the actual exchange rate. + double getInverseOfExchangeRate() => 1 / exchangeRate; +} diff --git a/packages/deriv_ui/lib/components/numpad/core/extensions/context_extension.dart b/packages/deriv_ui/lib/components/numpad/core/extensions/context_extension.dart new file mode 100644 index 000000000..b862b973d --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/extensions/context_extension.dart @@ -0,0 +1,19 @@ +// import 'package:deriv_numpad/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Extension methods for [BuildContext]. +extension ContextExtension on BuildContext { + /// Gets the device's screen size. + Size get screenSize => MediaQuery.of(this).size; + + /// A shortcut to access [T] from the ancestor's context. + /// + /// This does the same thing as the `InheritedWidget.of(context)` static method. + T provide() { + final T? widget = dependOnInheritedWidgetOfExactType(); + + assert(widget != null, '$T not found in context'); + + return widget!; + } +} diff --git a/packages/deriv_ui/lib/components/numpad/core/extensions/extensions.dart b/packages/deriv_ui/lib/components/numpad/core/extensions/extensions.dart new file mode 100644 index 000000000..1a39a4f81 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/extensions/extensions.dart @@ -0,0 +1 @@ +export 'context_extension.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/core/helpers/custom_website_status_helper.dart b/packages/deriv_ui/lib/components/numpad/core/helpers/custom_website_status_helper.dart new file mode 100644 index 000000000..d7547aa88 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/helpers/custom_website_status_helper.dart @@ -0,0 +1,5 @@ +/// Map currency names for some currency exceptions in [value] parameter. +String getStringWithMappedCurrencyName(String value) => + value.contains(RegExp('ust', caseSensitive: false)) + ? value.replaceAll(RegExp(r'ust', caseSensitive: false), 'USDT') + : value; diff --git a/packages/deriv_ui/lib/components/numpad/core/helpers/helpers.dart b/packages/deriv_ui/lib/components/numpad/core/helpers/helpers.dart new file mode 100644 index 000000000..208a0a4fb --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/helpers/helpers.dart @@ -0,0 +1,2 @@ +export 'number_helper.dart'; +export 'website_status_helper.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/core/helpers/number_helper.dart b/packages/deriv_ui/lib/components/numpad/core/helpers/number_helper.dart new file mode 100644 index 000000000..dc60bebe1 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/helpers/number_helper.dart @@ -0,0 +1,100 @@ +import 'package:decimal/decimal.dart'; + +/// Checks if the string value is an actual number value. +/// +/// Returns a bool value as the result. +bool isNumber(String? value) { + final String? replacedValue = value?.replaceAll(RegExp(r','), ''); + + return replacedValue != null && double.tryParse(replacedValue) != null; +} + +/// Checks if the value is in the range of two limits. +bool isBetweenLimits({ + required String value, + required double upperLimit, + required double lowerLimit, +}) { + final double? parsedValue = double.tryParse(value); + + return parsedValue != null && + (parsedValue <= upperLimit) && + (parsedValue >= lowerLimit); +} + +/// Checks if the [value] is equal or less than the [upperLimit] value. +bool isLessOrEqualLimit({required String value, required double upperLimit}) { + final double? parsedValue = double.tryParse(value); + + return parsedValue != null && parsedValue <= upperLimit; +} + +/// Checks if the [value] is equal or less than the [lowerLimit] value. +bool isMoreOrEqualLimit({required String value, required double lowerLimit}) { + final double? parsedValue = double.tryParse(value); + + return parsedValue != null && parsedValue >= lowerLimit; +} + +/// Checks if the [value] is bigger than `0` or not. +bool isPositive(String value) { + final double? num = double.tryParse(value); + + return num != null && num > 0; +} + +/// Checks if the number has correct number of [validDecimalNumber]. +bool hasValidPrecision({ + required String value, + required int validDecimalNumber, +}) { + final List splitValue = value.split('.'); + + if (splitValue.length == 2) { + return splitValue[1].length <= validDecimalNumber; + } + + return splitValue.length == 1; +} + +/// Returns the number value as a string without its currency symbol. +String? getAmountWithoutSymbol(String amount) { + try { + late int firstNumberIndex; + for (int i = 0; i < amount.length; i++) { + final String character = amount[i]; + if (isNumber(character)) { + firstNumberIndex = i; + break; + } + } + + return amount.substring(firstNumberIndex); + } on NoSuchMethodError { + return null; + } +} + +/// Checks the text value to be `empty` or `-`. +bool hasNoValue(String? text) => text == null || text.isEmpty || text == '-'; + +/// Parses string to number, returns `null` for invalid input. +double? getNumberFromString(String? value) => double.tryParse(value ?? ''); + +/// Adds `-` or `+` prefix to number. +String addSignPrefix({required double value, int fixedPoint = 0}) => + '${value > 0 ? '+' : ''}${value.toStringAsFixed(fixedPoint)}'; + +/// Gets the length of fractional digits of a passed [number]. +int getFractionalDigitsLength(num number) { + final String value = '${Decimal.parse('$number')}'; + + return value.contains('.') ? value.split('.').last.length : 0; +} + +/// Checks the double value whether it have Zeros as decimal number. +bool hasZeroAsDecimal(double number) => number % 1 == 0; + +/// Returns `2` as currency fractional digit if the fractional digit is null or zero. +int getCurrencyFractionalDigits(int? fractionalDigits) => + (fractionalDigits == null || fractionalDigits == 0) ? 2 : fractionalDigits; diff --git a/packages/deriv_ui/lib/components/numpad/core/helpers/popup_dialogs_helper.dart b/packages/deriv_ui/lib/components/numpad/core/helpers/popup_dialogs_helper.dart new file mode 100644 index 000000000..34490d67e --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/helpers/popup_dialogs_helper.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:deriv_ui/deriv_ui.dart'; +import 'package:flutter/material.dart'; + +/// Displays a pop alert dialog, usually retry and it is used when there is no connection to internet. +Future showAlertDialog({ + required BuildContext context, + String? title, + Widget? content, + String? positiveActionLabel, + String? negativeButtonLabel, + bool dismissible = true, + bool showLoadingIndicator = true, + bool useRootNavigator = true, + VoidCallback? onPositiveActionPressed, + VoidCallback? onNegativeActionPressed, +}) async { + final PopupAlertDialog popupAlertDialog = PopupAlertDialog( + title: title, + content: content, + showLoadingIndicator: showLoadingIndicator, + positiveButtonLabel: positiveActionLabel, + negativeButtonLabel: negativeButtonLabel, + onPositiveActionPressed: onPositiveActionPressed, + onNegativeActionPressed: onNegativeActionPressed, + ); + + await Future.delayed( + Duration.zero, + () => showDialog( + context: context, + barrierDismissible: dismissible, + useRootNavigator: useRootNavigator, + builder: (BuildContext context) => WillPopScope( + onWillPop: () async => dismissible, + child: popupAlertDialog, + ), + ), + ); +} diff --git a/packages/deriv_ui/lib/components/numpad/core/helpers/website_status_helper.dart b/packages/deriv_ui/lib/components/numpad/core/helpers/website_status_helper.dart new file mode 100644 index 000000000..d7547aa88 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/helpers/website_status_helper.dart @@ -0,0 +1,5 @@ +/// Map currency names for some currency exceptions in [value] parameter. +String getStringWithMappedCurrencyName(String value) => + value.contains(RegExp('ust', caseSensitive: false)) + ? value.replaceAll(RegExp(r'ust', caseSensitive: false), 'USDT') + : value; diff --git a/packages/deriv_ui/lib/components/numpad/core/widgets/currency_switcher.dart b/packages/deriv_ui/lib/components/numpad/core/widgets/currency_switcher.dart new file mode 100644 index 000000000..6570ec593 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/widgets/currency_switcher.dart @@ -0,0 +1,74 @@ +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../models/models.dart'; +import '../../core/core.dart'; + +/// A widget that reflects the exchanged currency of what every typed in currency textfield. +class CurrencySwitcher extends StatelessWidget { + /// Creates an instance of [CurrencySwitcher]. + const CurrencySwitcher({ + required this.currencyDetail, + this.onTap, + }); + + /// Data class for currency information to show in this widget. + final CurrencyDetail currencyDetail; + + /// call back when this widget is pressed. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + border: Border.all( + color: context.theme.colors.general, + ), + borderRadius: BorderRadius.circular(ThemeProvider.borderRadius08), + ), + child: InkWell( + borderRadius: BorderRadius.circular(ThemeProvider.borderRadius08), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: ThemeProvider.margin08, + vertical: ThemeProvider.margin04, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + currencyDetail.displayAmount.isEmpty + ? const SizedBox.shrink() + : _CurrencyText('${currencyDetail.displayAmount} '), + _CurrencyText(currencyDetail.displayCurrency), + const SizedBox(width: ThemeProvider.margin04), + SvgPicture.asset( + swapIcon, + height: ThemeProvider.iconSize16, + width: ThemeProvider.iconSize16, + colorFilter: ColorFilter.mode( + context.theme.colors.prominent, + BlendMode.srcIn, + ), + ) + ], + ), + ), + ), + ); +} + +class _CurrencyText extends StatelessWidget { + const _CurrencyText(this.message); + + final String message; + + @override + Widget build(BuildContext context) => Text( + message, + style: context.theme.textStyle( + textStyle: TextStyles.body1, + color: context.theme.colors.general, + ), + ); +} diff --git a/packages/deriv_ui/lib/components/numpad/core/widgets/widgets.dart b/packages/deriv_ui/lib/components/numpad/core/widgets/widgets.dart new file mode 100644 index 000000000..2d26f0ab6 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/core/widgets/widgets.dart @@ -0,0 +1 @@ +export 'currency_switcher.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/models/currency_detail.dart b/packages/deriv_ui/lib/components/numpad/models/currency_detail.dart new file mode 100644 index 000000000..89871fc8a --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/models/currency_detail.dart @@ -0,0 +1,35 @@ +import '../core/helpers/custom_website_status_helper.dart'; + +/// This is a data class for storing the currency data like amount, currencyType. +class CurrencyDetail { + /// Data class for currency.Here, + /// + /// [amount] - amount in this currency. + /// + /// [currencyType] - type of currency like: 'USD','AUD',BTC,'ETH' and so on. + CurrencyDetail(this.amount, this.currencyType); + + /// Available fiat currency that Deriv supports. + static List fiatCurrencies = ['USD', 'AUD', 'EUR', 'GBP']; + + /// Amount passed for exchance. + final double amount; + + /// Type of the currency whether be it crypto or fiat. Eg: USD,BTC,ETH,AUD and so on. + final String currencyType; + + /// This will check if this instance is a fiat type currency or not. + bool get isFiat => fiatCurrencies.contains(currencyType); + + /// Amount that is used to display for user. + String get displayAmount { + if (amount == 0.0) { + return ''; + } + + return isFiat ? amount.toStringAsFixed(2) : amount.toStringAsFixed(8); + } + + /// This method is used to display currency for user. + String get displayCurrency => getStringWithMappedCurrencyName(currencyType); +} diff --git a/packages/deriv_ui/lib/components/numpad/models/exchange_rate_model.dart b/packages/deriv_ui/lib/components/numpad/models/exchange_rate_model.dart new file mode 100644 index 000000000..aa5785d14 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/models/exchange_rate_model.dart @@ -0,0 +1,18 @@ +/// A data class for exchange rate. +class ExchangeRateModel { + /// A data class for exchange rate. + ExchangeRateModel({ + required this.baseCurrency, + required this.targetCurrency, + required this.exchangeRate, + }); + + /// This currency denotes the main currency we are exchanging from. + final String baseCurrency; + + /// This currency denotes the targetted currency we are exchanging to. + final String targetCurrency; + + /// This is the current exchange rate. The unit of this amount is [targetCurrency]. + final double exchangeRate; +} diff --git a/packages/deriv_ui/lib/components/numpad/models/models.dart b/packages/deriv_ui/lib/components/numpad/models/models.dart new file mode 100644 index 000000000..060347e8a --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/models/models.dart @@ -0,0 +1,4 @@ +export 'currency_detail.dart'; +export 'exchange_rate_model.dart'; +export 'number_pad_data.dart'; +export 'number_pad_label.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/models/number_pad_data.dart b/packages/deriv_ui/lib/components/numpad/models/number_pad_data.dart new file mode 100644 index 000000000..9169a8882 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/models/number_pad_data.dart @@ -0,0 +1,11 @@ +/// [NumberPadData] for [NumberPad] widget +class NumberPadData { + /// The data model that is returned from [NumberPad] widget + NumberPadData({this.firstInputValue, this.secondInputValue}); + + /// First input data + final double? firstInputValue; + + /// Second input data + final double? secondInputValue; +} diff --git a/packages/deriv_ui/lib/components/numpad/models/number_pad_label.dart b/packages/deriv_ui/lib/components/numpad/models/number_pad_label.dart new file mode 100644 index 000000000..1d212fbc6 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/models/number_pad_label.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +/// A class that will hold all the strings required by the NumberPad widget. +class NumberPadLabel { + /// Initialize [NumberPadLabel]. + NumberPadLabel({ + required this.actionOK, + this.semanticNumberPadBottomSheetHandle, + this.warnValueCantBeLessThan, + this.warnValueCantBeGreaterThan, + this.warnDoubleInputValueCantBeLessThan, + this.warnDoubleInputValueCantBeGreaterThan, + this.warnValueShouldBeInRange, + this.onValidate, + }); + + /// Semantic label for the handle svg at the top of the NumberPad bottom sheet. + final String? semanticNumberPadBottomSheetHandle; + + /// The text on the 'OK' button that's in the AlertDialgue. + final String actionOK; + + /// `{Input} can't be less than {minAmount} {currencySymbol}` + final String Function( + Object input, + Object minAmount, + Object currencySymbol, + )? warnValueCantBeLessThan; + + /// `{Input} can't be greater than {maxAmount} {currencySymbol}` + final String Function( + Object input, + Object maxAmount, + Object currencySymbol, + )? warnValueCantBeGreaterThan; + + /// `Invalid {Input}. {Input} can't be less than {minAmount} {currencySymbol}` + final String Function( + Object input, + Object minAmount, + Object currencySymbol, + )? warnDoubleInputValueCantBeLessThan; + + /// `Invalid {Input}. {Input} can't be greater than {maxAmount} {currencySymbol}` + final String Function( + Object input, + Object maxAmount, + Object currencySymbol, + )? warnDoubleInputValueCantBeGreaterThan; + + /// `{Input} between {minAmountClear} {currencySymbol} and {maxAmount} {currencySymbol}` + final String Function( + Object input, + Object minAmountClear, + Object currencySymbol, + Object maxAmount, + )? warnValueShouldBeInRange; + + /// With this, client code can have their own validation logic. + /// Returning [RichText] means this will show the message in the UI + /// Returning [null] means it won't show the message in the UI. + final RichText? Function(String)? onValidate; +} diff --git a/packages/deriv_ui/lib/components/numpad/numpad.dart b/packages/deriv_ui/lib/components/numpad/numpad.dart new file mode 100644 index 000000000..946b7b960 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/numpad.dart @@ -0,0 +1,3 @@ +export 'widgets/widgets.dart'; +export 'models/models.dart'; +export 'core/core.dart'; diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad.dart new file mode 100644 index 000000000..384bb0492 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad.dart @@ -0,0 +1,693 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:deriv_theme/deriv_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:intl/intl.dart'; +import 'package:deriv_ui/widgets/widgets.dart'; + +import '../core/core.dart'; +import '../models/models.dart'; + +part 'number_pad_animated_message.dart'; + +part 'number_pad_double_textfields.dart'; + +part 'number_pad_key_item.dart'; + +part 'number_pad_keypad.dart'; + +part 'number_pad_message.dart'; + +part 'number_pad_provider.dart'; + +part 'number_pad_single_text_title.dart'; + +part 'number_pad_single_textfield.dart'; + +part 'number_pad_text_field.dart'; + +/// A function that is called when the user pressed any button from the keypad. +/// +/// This callback provides [BuildContext], current focused [TextEditingController] +/// and the most recent input as a [String] parameter. +typedef NumberPadKeyPressedCallback = Function( + BuildContext, TextEditingController, String); + +/// A function that is called when [NumberPad] has closed either by pressing OK +/// button or by touching outside of the [NumberPad] widget. +/// +/// This callback provides two parameters: +/// +/// [NumberPadWidgetType] which indicates that there is one input field or two input fields. +/// +/// [NumberPadData] which contains two double values, one for each of the input values. +/// [closeType] specifies the way that the used have closed the [NumberPad] +/// If the input value equals [noInput] (-) for any of the input the corresponding +/// [NumberPadData] value will be null. +typedef NumberPadCloseCallback = Function(NumberPadWidgetType type, + NumberPadCloseType closeType, NumberPadData result); + +/// Input value when the user press ok button on keypad. +const String applyValuesInput = 'OK'; + +/// Input value when the user press backspace button to remove input. +const String backspaceInput = '<-'; + +/// Default input value when there is no initial value or all characters removed. +const String noInput = ''; + +/// Input value to add decimals as input +const String point = '.'; + +/// Data value which is returned when there is no input in [TextField]s and also for +/// second input data when [NumberPadWidgetType] is [NumberPadWidgetType.singleInput] +const String? returnedValueForEmpty = null; + +/// NumberPad widget +class NumberPad extends StatefulWidget { + /// This widget helps to display single or double input fields with customizable + /// title, initial value and also to set minimum and maximum for each input separately + /// + /// [onOpen] event is called when the NumberPad widget has opened. + /// + /// [onClose] event is called when the user clicks apply button or clicks outside the + /// bottom sheet. [onClose] event returns two parameters: + /// + /// [NumberPadWidgetType] which indicates that there is one input field or two input fields. + /// + /// [formatter] and [NumberPadWidgetType] are required parameters + /// and other parameters are optional. + /// + /// If any of [TextField]s has [noInput] (-) value it returns the initial value and of the + /// initial value is null it returns null as the result in [NumberPadData]. + /// + /// This widget is dismissible by click OK button or by touching anywhere outside number pad + const NumberPad({ + required this.formatter, + required this.numberPadType, + required this.label, + this.currency, + this.firstInputTitle = '', + this.secondInputTitle = '', + this.maxInputLength = 11, + this.firstInputInitialValue, + this.secondInputInitialValue, + this.firstInputMinimumValue = 0, + this.firstInputMaximumValue = double.maxFinite, + this.secondInputMinimumValue = 0, + this.secondInputMaximumValue = double.maxFinite, + this.onOpen, + this.onClose, + this.currentFocus = NumberPadInputFocus.firstInputField, + this.dialogDescription, + this.headerLeading, + Key? key, + }) : super(key: key); + + /// This is the instance of Numberpad which has currency exchanger within it. + /// + /// It will return [NumberPadData] which contains the latest currency amount. + factory NumberPad.withCurrencyExchanger({ + /// This information will be prefilled in the textField + required CurrencyDetail primaryCurrency, + + /// stream of exchange rate of the currencies. Here, + /// [base_currency] should be same as [currencyType] in `primaryCurrency`. + /// [target_currency] will be the currency shown in currency switcher. + /// When there is a new exchange rate in this stream, the value in currency switcher changes. + required Stream exchangeRatesStream, + + /// The initial exchange rate for the currency provided. + required ExchangeRateModel initialExchangeRate, + + /// Any validation for currencies. + required NumberPadLabel label, + + /// Calls when this widget is closed. + NumberPadCloseCallback? onClose, + String title = '', + }) => + _NumpadWithExchange( + label: label, + primaryCurrency: primaryCurrency, + exchangeRatesStream: exchangeRatesStream, + initialExchangeRate: initialExchangeRate, + title: title, + onClose: onClose, + ); + + /// Sets the currency of the number pad + /// + /// The currency code that will be shown on the right side of the number pad text input area. + final String? currency; + + /// Sets the format of input text + /// + /// This length applies to all available [TextField]s exist in the [BottomSheet] + final NumberFormat formatter; + + /// Maximum possible input characters for input values. + /// + /// This length is applied to all available [TextField]s exist in the [BottomSheet]. + final int maxInputLength; + + /// Class that contains all the texts used in the [NumberPad]. + final NumberPadLabel label; + + /// Sets the number of the [TextField]s in [BottomSheet]. + /// + /// [NumberPadWidgetType.singleInput] shows one [TextField]. + /// + /// [NumberPadWidgetType.doubleInput] shows two [TextField]s. + final NumberPadWidgetType numberPadType; + + /// The Title which is displayed at the top of the first Input value. + /// + /// When using [NumberPadWidgetType.singleInput], this is the title of main [TextField]. + /// + /// When using [NumberPadWidgetType.doubleInput], this title is displayed at top of the left side [TextField]. + /// + /// The default value is null. + final String firstInputTitle; + + /// The title to be displayed at the top of the second [TextField]. + /// + /// It will be only displayed when [numberPadType] is [NumberPadWidgetType.doubleInput]. + final String secondInputTitle; + + /// The initial value of first [TextField]. + /// + /// When using [NumberPadWidgetType.singleInput], this is the initial value of + /// the main [TextField]. + /// + /// When using [NumberPadWidgetType.doubleInput], this is the initial value + /// of the left [TextField]. + final double? firstInputInitialValue; + + /// The initial value of the second [TextField] on the right and only used + /// by setting [numberPadType] to [NumberPadWidgetType.doubleInput]. + final double? secondInputInitialValue; + + /// The callback that is called when the [NumberPad] BottomSheet has been opened. + final VoidCallback? onOpen; + + /// The callback that is called when the user taps outside of the [BottomSheet] or + /// when the user submits the changes. + final NumberPadCloseCallback? onClose; + + /// The minimum allowed input value of the first input. + /// + /// By default, no minimum limit will be applied. + final double? firstInputMinimumValue; + + /// The maximum allowed input value of the first input. + /// + /// By default, no maximum limit will be applied. + final double firstInputMaximumValue; + + /// The minimum allowed input value of the second input. + /// + /// By default, no minimum limit will be applied. + final double secondInputMinimumValue; + + /// The maximum allowed input value of the second input. + /// + /// By default, no maximum limit will be applied. + final double? secondInputMaximumValue; + + /// The focused input field when the [NumberPad] opens. + /// + /// By default, should be the first input field. + final NumberPadInputFocus currentFocus; + + /// The description text displayed when the [InfoIconButton] on the header of + /// this [NumberPad] is pressed. + /// + /// If not set, the [InfoIconButton] will not be displayed. + final String? dialogDescription; + + /// The leading widget on the header of this [NumberPad]. + final Widget? headerLeading; + + @override + State createState() => _NumberPadState(); +} + +class _NumberPadState extends State { + late String _currency; + late NumberFormat _formatter; + TextEditingController? _firstInputController; + TextEditingController? _secondInputController; + late ExchangeController _exchangeController; + late FocusNode _firstInputFocusNode; + FocusNode? _secondInputFocusNode; + + @override + void initState() { + super.initState(); + + _currency = widget.currency ?? ''; + _formatter = widget.formatter; + _firstInputController = TextEditingController(); + _firstInputFocusNode = FocusNode(); + _firstInputController?.text = _getFirstInputControllerText(); + + if (widget.numberPadType == NumberPadWidgetType.doubleInput) { + _secondInputController = TextEditingController(); + _secondInputFocusNode = FocusNode(); + _secondInputController?.text = widget.secondInputInitialValue == null + ? noInput + : _formatter.format(widget.secondInputInitialValue); + } + + if (widget.currentFocus == NumberPadInputFocus.firstInputField) { + _firstInputFocusNode.requestFocus(); + } else { + _secondInputFocusNode?.requestFocus(); + } + + widget.onOpen?.call(); + } + + String _getFirstInputControllerText() { + if (widget.firstInputInitialValue != null) { + return _formatter.format(widget.firstInputInitialValue); + } else { + return noInput; + } + } + + @override + Widget build(BuildContext ctx) => _buildWholeNumpad(context); + + Widget _buildWholeNumpad(BuildContext context) => _NumberPadProvider( + type: widget.numberPadType, + label: widget.label, + formatter: _formatter, + currency: _currency, + firstInputController: _firstInputController, + secondInputController: _secondInputController, + firstInputFocusNode: _firstInputFocusNode, + secondInputFocusNode: _secondInputFocusNode, + firstInputMinimumValue: widget.firstInputMinimumValue, + firstInputMaximumValue: widget.firstInputMaximumValue, + secondInputMinimumValue: widget.secondInputMinimumValue, + secondInputMaximumValue: widget.secondInputMaximumValue, + focusedInput: _getFocusedInput, + isSecondInputInRange: _isSecondInputInRange, + isFirstInputInRange: _isFirstInputInRange, + isAllInputsValid: _isAllInputsValid, + child: Builder( + builder: (BuildContext context) => WillPopScope( + onWillPop: () async { + _applyInputs(NumberPadCloseType.clickOutsideView); + + return Future.value(true); + }, + child: ListView( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + Container( + decoration: BoxDecoration( + color: context.theme.colors.primary, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(ThemeProvider.borderRadius16), + topRight: Radius.circular(ThemeProvider.borderRadius16), + ), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: ThemeProvider.margin16, + ), + decoration: BoxDecoration( + color: context.theme.colors.secondary, + borderRadius: const BorderRadius.only( + topLeft: + Radius.circular(ThemeProvider.borderRadius16), + topRight: + Radius.circular(ThemeProvider.borderRadius16), + ), + ), + child: Align( + child: InkWell( + child: Padding( + padding: + const EdgeInsets.all(ThemeProvider.margin08), + child: SvgPicture.asset( + handleIcon, + width: ThemeProvider.margin40, + height: ThemeProvider.margin04, + semanticsLabel: widget + .label.semanticNumberPadBottomSheetHandle, + ), + ), + onTap: () => Navigator.pop(context), + ), + ), + ), + widget.numberPadType == NumberPadWidgetType.singleInput + ? _NumberPadSingleTextField( + leading: widget.headerLeading, + title: widget.firstInputTitle, + dialogDescription: widget.dialogDescription, + ) + : _NumberPadDoubleTextFields( + firstTitleValue: widget.firstInputTitle, + secondTitleValue: widget.secondInputTitle, + ), + getCustomValidationText(context) != null + ? _NumberPadMessage( + messageText: getCustomValidationText(context)) + : _NumberPadMessage(message: _validateMessage()), + _NumberPadKeypadWidget( + onKeyPressed: _onKeyboardButtonPressed, + ) + ], + ), + ), + ], + ), + ), + ), + ); + + void _onKeyboardButtonPressed( + BuildContext context, + TextEditingController controller, + String text, + ) { + switch (text) { + case applyValuesInput: + if (ExchangeNotifier.of(context) != null) { + final double returningValue = + ExchangeNotifier.of(context)!.finalAmount(); + final NumberPadData data = NumberPadData( + firstInputValue: returningValue, + ); + widget.onClose?.call(NumberPadWidgetType.doubleInput, + NumberPadCloseType.pressOK, data); + return Navigator.of(context).pop(data); + } + _applyInputs(NumberPadCloseType.pressOK); + break; + case backspaceInput: + _handleBackSpace(controller); + break; + + default: + if (_isInputValid(controller, text)) { + _setNewAmount(controller, text); + } + } + ExchangeNotifier.of(context)?.onChangeCurrency(controller.text); + } + + void _applyInputs(NumberPadCloseType closeStyle) { + String? secondValue = returnedValueForEmpty; + // TODO(Emad): replace ternaries with if conditions. + final String? firstValue = hasNoValue(_firstInputController?.text) || + !isBetweenLimits( + value: _firstInputController?.text ?? '', + upperLimit: widget.firstInputMaximumValue, + lowerLimit: widget.firstInputMinimumValue ?? 0, + ) + ? widget.firstInputInitialValue == null + ? returnedValueForEmpty + : closeStyle == NumberPadCloseType.clickOutsideView + ? '${widget.firstInputInitialValue}' + : returnedValueForEmpty + : _firstInputController?.text; + + if (widget.numberPadType == NumberPadWidgetType.doubleInput) { + // TODO(Emad): replace ternaries with if conditions. + secondValue = hasNoValue(_secondInputController?.text) || + !isBetweenLimits( + value: _secondInputController?.text ?? '', + upperLimit: widget.secondInputMaximumValue!, + lowerLimit: widget.secondInputMinimumValue, + ) + ? widget.secondInputInitialValue == null + ? returnedValueForEmpty + : closeStyle == NumberPadCloseType.clickOutsideView + ? '${widget.secondInputInitialValue}' + : returnedValueForEmpty + : _secondInputController?.text; + } + final NumberPadData data = NumberPadData( + firstInputValue: firstValue == null ? null : double.tryParse(firstValue), + secondInputValue: + secondValue == null ? null : double.tryParse(secondValue), + ); + + widget.onClose?.call(NumberPadWidgetType.doubleInput, closeStyle, data); + + Navigator.pop(context, data); + } + + RichText? getCustomValidationText(BuildContext context) { + final RichText? Function(String value)? onValidate = + _NumberPadProvider.of(context)?.label.onValidate; + if (onValidate == null) { + return null; + } else { + final RichText? richText = onValidate(_firstInputController?.text ?? ''); + return richText; + } + } + + String _validateMessage() { + String message = ''; + + if (!hasNoValue(_firstInputController?.text)) { + final bool isFirstLessThanMax = isLessOrEqualLimit( + value: _firstInputController?.text ?? '', + upperLimit: widget.firstInputMaximumValue); + + final bool isFirstMoreThanMin = isMoreOrEqualLimit( + value: _firstInputController?.text ?? '', + lowerLimit: widget.firstInputMinimumValue ?? 0); + + if (!isFirstMoreThanMin) { + final String Function( + Object input, Object minAmount, Object currencySymbol)? + callback = widget.label.warnValueCantBeLessThan; + return message = callback?.call( + widget.firstInputTitle, + widget.firstInputMinimumValue ?? 0, + getStringWithMappedCurrencyName(_currency), + ) ?? + ''; + } else if (!isFirstLessThanMax) { + final String Function( + Object input, Object maxAmount, Object currencySymbol)? + callback = widget.label.warnValueCantBeGreaterThan; + return message = callback?.call( + widget.firstInputTitle, + widget.firstInputMaximumValue, + getStringWithMappedCurrencyName(_currency), + ) ?? + ''; + } + } + if (widget.numberPadType == NumberPadWidgetType.doubleInput) { + if (!hasNoValue(_secondInputController?.text)) { + final bool isSecondLessThanMax = isLessOrEqualLimit( + value: _secondInputController?.text ?? '', + upperLimit: widget.secondInputMaximumValue!); + + final bool isSecondMoreThanMin = isMoreOrEqualLimit( + value: _secondInputController?.text ?? '', + lowerLimit: widget.secondInputMinimumValue); + + if (!isSecondMoreThanMin) { + final String Function( + Object input, Object minAmount, Object currencySymbol)? + callback = widget.label.warnDoubleInputValueCantBeLessThan; + return message = callback?.call( + widget.secondInputTitle, + widget.secondInputMinimumValue, + getStringWithMappedCurrencyName(_currency), + ) ?? + ''; + } else if (!isSecondLessThanMax) { + final String Function( + Object input, Object maxAmount, Object currencySymbol)? + callback = widget.label.warnDoubleInputValueCantBeGreaterThan; + return message = callback?.call( + widget.secondInputTitle, + widget.secondInputMaximumValue!, + getStringWithMappedCurrencyName(_currency), + ) ?? + ''; + } + } + } else if (widget.firstInputMinimumValue != null && + widget.firstInputMaximumValue != double.maxFinite) { + final String Function( + Object input, + Object minAmountClear, + Object currencySymbol, + Object maxAmount)? callback = widget.label.warnValueShouldBeInRange; + return callback?.call( + widget.firstInputTitle, + widget.firstInputMinimumValue ?? 0, + getStringWithMappedCurrencyName(_currency), + widget.firstInputMaximumValue, + ) ?? + ''; + } + return message; + } + + void _handleBackSpace(TextEditingController controller) { + final String text = controller.text; + if (text.isEmpty) { + return; + } + + final String lastCharacter = text.substring(text.length - 1); + final String remaining = text.substring(0, text.length - 1); + + if (isNumber(lastCharacter) || lastCharacter == point) { + controller.clear(); + _setNewAmount(controller, remaining); + } + } + + void _setNewAmount(TextEditingController controller, String newAmount) { + if (controller.text.isNotEmpty && + controller.text == '0' && + newAmount != point) { + controller.clear(); + } + + if (controller.text.isEmpty && newAmount == point) { + controller.text = '0'; + } + + controller.text = controller.text + newAmount; + final TextSelection selection = + TextSelection.collapsed(offset: controller.text.length); + controller.selection = selection; + + setState(() {}); + } + + bool _isInputValid(TextEditingController controller, String input) { + /// Increment maxInputLength by 1 because it counts the symbol one character + final String currentText = controller.text; + if (currentText.length >= (widget.maxInputLength + 1)) { + return false; + } else if (currentText == '0' && input == '0') { + return false; + } else if (currentText.contains(point) && input == point) { + return false; + } else if (!hasValidPrecision( + value: '$currentText$input', + validDecimalNumber: _formatter.maximumFractionDigits)) { + return false; + } + return true; + } + + TextEditingController? _getFocusedInput() { + if (widget.numberPadType == NumberPadWidgetType.singleInput) { + return _firstInputController; + } else if (widget.numberPadType == NumberPadWidgetType.doubleInput) { + if (_firstInputFocusNode.hasFocus) { + return _firstInputController; + } else { + return _secondInputController; + } + } + return null; + } + + bool _isAllInputsValid() { + if (widget.numberPadType == NumberPadWidgetType.singleInput) { + return _isFirstInputInRange(); + } else { + return _isFirstInputInRange() && _isSecondInputInRange(); + } + } + + bool _isFirstInputInRange() => + !hasNoValue(_firstInputController?.text) && + isBetweenLimits( + value: _firstInputController?.text ?? '', + upperLimit: widget.firstInputMaximumValue, + lowerLimit: widget.firstInputMinimumValue ?? 0, + ); + + bool _isSecondInputInRange() => + hasNoValue(_secondInputController?.text) || + isBetweenLimits( + value: _secondInputController?.text ?? '', + upperLimit: widget.secondInputMaximumValue!, + lowerLimit: widget.secondInputMinimumValue, + ); +} + +class _NumpadWithExchange extends NumberPad { + _NumpadWithExchange({ + required NumberPadLabel label, + required this.primaryCurrency, + required this.exchangeRatesStream, + required this.initialExchangeRate, + required String title, + NumberPadCloseCallback? onClose, + }) : super( + numberPadType: NumberPadWidgetType.singleInput, + formatter: NumberFormat.decimalPattern()..maximumFractionDigits = 8, + label: label, + firstInputTitle: title, + onClose: onClose, + ); + + final CurrencyDetail primaryCurrency; + + final Stream exchangeRatesStream; + + final ExchangeRateModel initialExchangeRate; + + @override + State createState() => _NumberPadWithExchangeState( + exchangeRatesStream: exchangeRatesStream, + initialExchangeRate: initialExchangeRate, + primaryCurrency: primaryCurrency, + ); +} + +class _NumberPadWithExchangeState extends _NumberPadState { + _NumberPadWithExchangeState({ + required this.primaryCurrency, + required this.exchangeRatesStream, + required this.initialExchangeRate, + }); + final CurrencyDetail primaryCurrency; + final Stream exchangeRatesStream; + final ExchangeRateModel initialExchangeRate; + + @override + void initState() { + super.initState(); + super._firstInputController!.text = primaryCurrency.displayAmount; + _exchangeController = ExchangeController( + primaryCurrency: primaryCurrency, + currencyFieldController: super._firstInputController!, + rateSource: exchangeRatesStream, + initialExchangeRate: initialExchangeRate, + ); + } + + @override + Widget build(BuildContext context) => ExchangeNotifier( + child: super.build(context), + notifier: _exchangeController, + ); +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_animated_message.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_animated_message.dart new file mode 100644 index 000000000..9451962de --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_animated_message.dart @@ -0,0 +1,52 @@ +part of 'number_pad.dart'; + +class _NumberPadAnimatedMessage extends StatefulWidget { + const _NumberPadAnimatedMessage({ + this.child, + this.animationDuration = const Duration(milliseconds: 400), + Key? key, + }) : super(key: key); + + final Widget? child; + final Duration animationDuration; + + @override + State createState() => _NumberPadAnimatedMessageState(); +} + +class _NumberPadAnimatedMessageState extends State<_NumberPadAnimatedMessage> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + final Tween _tween = Tween(begin: 0.75, end: 1); + + @override + void initState() { + super.initState(); + + _controller = + AnimationController(vsync: this, duration: widget.animationDuration); + _scaleAnimation = + CurvedAnimation(parent: _controller, curve: Curves.bounceOut); + + _scaleAnimation = _tween.animate(_scaleAnimation); + + _controller + ..addListener(() => setState(() {})) + ..forward(); + } + + @override + void dispose() { + _controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) => ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ); +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_double_textfields.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_double_textfields.dart new file mode 100644 index 000000000..294ed29bc --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_double_textfields.dart @@ -0,0 +1,70 @@ +part of 'number_pad.dart'; + +class _NumberPadDoubleTextFields extends StatelessWidget { + const _NumberPadDoubleTextFields({ + required this.firstTitleValue, + required this.secondTitleValue, + }); + + final String firstTitleValue; + final String secondTitleValue; + + @override + Widget build(BuildContext context) { + final _NumberPadProvider? numPadProvider = _NumberPadProvider.of(context); + + return Row( + children: [ + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: ThemeProvider.margin24, + top: ThemeProvider.margin24, + bottom: ThemeProvider.margin24, + ), + child: _NumberPadTextField( + controller: numPadProvider!.firstInputController, + textStyle: TextStyles.subheading, + focusNode: numPadProvider.firstInputFocusNode, + inputValidator: numPadProvider.isFirstInputInRange, + label: firstTitleValue, + ), + ), + ), + ), + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: ThemeProvider.margin24, + horizontal: ThemeProvider.margin08, + ), + child: _NumberPadTextField( + controller: numPadProvider.secondInputController, + textStyle: TextStyles.subheading, + focusNode: numPadProvider.secondInputFocusNode, + inputValidator: numPadProvider.isSecondInputInRange, + label: secondTitleValue, + ), + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.only( + right: ThemeProvider.margin24, + ), + child: Text( + getStringWithMappedCurrencyName(numPadProvider.currency), + style: context.theme.textStyle( + textStyle: TextStyles.headlineNormal, + color: context.theme.colors.disabled, + ), + ), + ), + ), + ], + ); + } +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_key_item.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_key_item.dart new file mode 100644 index 000000000..9ca7ff73c --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_key_item.dart @@ -0,0 +1,138 @@ +part of 'number_pad.dart'; + +class _NumberPadKey extends StatefulWidget { + const _NumberPadKey({ + required this.onPressed, + required this.ignoring, + required this.index, + required this.actionOK, + }); + + final Function(BuildContext, TextEditingController, String) onPressed; + + final bool ignoring; + + final int index; + + final String actionOK; + + @override + _NumberPadKeyState createState() => _NumberPadKeyState(); +} + +class _NumberPadKeyState extends State<_NumberPadKey> { + Timer? _timer; + + static const List _keyboardContent = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + point, + '0', + backspaceInput, + applyValuesInput + ]; + + /// The delay time that will be used when deleting the characters + /// on long press backspace button. + static const int delayTime = 80; + + /// The number of ticks that will be used when before deleting second symbol + /// in input field. + static const int tickPause = 10; + + @override + Widget build(BuildContext context) { + final _NumberPadProvider? numberPadProvider = + _NumberPadProvider.of(context); + final String text = _keyboardContent[widget.index]; + + return Builder( + builder: (BuildContext context) => Expanded( + child: SizedBox.expand( + child: IgnorePointer( + ignoring: _isIgnoring(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + foregroundDecoration: BoxDecoration( + color: _isIgnoring() + ? Colors.black.withOpacity(0.5) + : Colors.transparent, + ), + child: GestureDetector( + onLongPressStart: (_) { + if (text == backspaceInput) { + widget.onPressed( + context, + numberPadProvider!.focusedInput()!, + text, + ); + _timer = Timer.periodic( + const Duration(milliseconds: delayTime), + (Timer timer) { + if (timer.tick < tickPause) { + return; + } + widget.onPressed( + context, + numberPadProvider.focusedInput()!, + text, + ); + }, + ); + } + }, + onLongPressEnd: (_) { + if (text == backspaceInput) { + _timer?.cancel(); + } + }, + child: TextButton( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + side: BorderSide(color: context.theme.colors.primary), + ), + backgroundColor: (text == applyValuesInput) + // TODO(emad): check Account to be loaded + ? (true + ? context.theme.colors.coral + // ignore: dead_code + : context.theme.colors.disabled) + : context.theme.colors.secondary, + ), + child: text == backspaceInput + ? Icon( + Icons.backspace, + size: 18, + color: context.theme.colors.prominent, + ) + : Text( + text == applyValuesInput ? widget.actionOK : text, + style: context.theme + .textStyle(textStyle: TextStyles.button), + ), + onPressed: () { + widget.onPressed( + context, + numberPadProvider!.focusedInput()!, + text, + ); + }, + ), + ), + ), + ), + ), + ), + ); + } + + bool _isIgnoring() => + _keyboardContent[widget.index] == applyValuesInput && !widget.ignoring; +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_keypad.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_keypad.dart new file mode 100644 index 000000000..e6eb29b8c --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_keypad.dart @@ -0,0 +1,57 @@ +part of 'number_pad.dart'; + +class _NumberPadKeypadWidget extends StatefulWidget { + const _NumberPadKeypadWidget({ + required this.onKeyPressed, + }); + + final NumberPadKeyPressedCallback onKeyPressed; + + @override + _NumberPadKeypadWidgetState createState() => _NumberPadKeypadWidgetState(); +} + +class _NumberPadKeypadWidgetState extends State<_NumberPadKeypadWidget> { + static const double _smallestScreenHeight = 450; + static const double _buttonsRatio = 0.4; + static const int _numberOfRows = 5; + static const int _numberOfColumns = 3; + + /// There are 3 columns in keyboard that amount of w/h of each one is 0.4 (48/120) + double get _keyboardHeight => + (MediaQuery.of(context).size.width / _numberOfColumns) * + _buttonsRatio * + _numberOfRows; + + double get _overflowSpace => + _smallestScreenHeight - MediaQuery.of(context).size.height; + + @override + Widget build(BuildContext context) => SizedBox( + height: MediaQuery.of(context).size.height >= _smallestScreenHeight + ? _keyboardHeight + : _keyboardHeight - _overflowSpace, + child: Column( + children: List.generate( + _numberOfRows, + (int rowCounter) => _buildKeyboardRows(rowCounter), + ), + ), + ); + + Widget _buildKeyboardRows(int row) => Expanded( + child: Row( + children: + List.generate((row != 4) ? 3 : 1, (int columnCounter) { + final int counter = (row != 4) ? row * _numberOfColumns : 12; + final int index = columnCounter + counter; + return _NumberPadKey( + index: index, + ignoring: _NumberPadProvider.of(context)!.isAllInputsValid(), + onPressed: widget.onKeyPressed, + actionOK: _NumberPadProvider.of(context)!.label.actionOK, + ); + }), + ), + ); +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_message.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_message.dart new file mode 100644 index 000000000..8fe2f5dca --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_message.dart @@ -0,0 +1,44 @@ +part of 'number_pad.dart'; + +class _NumberPadMessage extends StatelessWidget { + const _NumberPadMessage({ + this.message, + this.messageText, + }) : assert(message != null || messageText != null); + + final RichText? messageText; + final String? message; + + @override + Widget build(BuildContext context) { + final _NumberPadProvider? numberPadProvider = + _NumberPadProvider.of(context); + + return _NumberPadAnimatedMessage( + animationDuration: const Duration(milliseconds: 800), + child: AnimatedContainer( + alignment: Alignment.center, + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.only( + bottom: ThemeProvider.margin08, + left: ThemeProvider.margin16, + right: ThemeProvider.margin16, + ), + child: messageText != null + ? messageText! + : Text( + message!, + style: numberPadProvider!.isAllInputsValid() + ? context.theme.textStyle( + textStyle: TextStyles.caption, + color: context.theme.colors.disabled, + ) + : context.theme.textStyle( + textStyle: TextStyles.captionBold, + color: context.theme.colors.coral, + ), + ), + ), + ); + } +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_provider.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_provider.dart new file mode 100644 index 000000000..dac347242 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_provider.dart @@ -0,0 +1,54 @@ +part of 'number_pad.dart'; + +class _NumberPadProvider extends InheritedWidget { + const _NumberPadProvider({ + required Widget child, + required this.type, + required this.currency, + required this.formatter, + required this.focusedInput, + required this.firstInputFocusNode, + required this.isFirstInputInRange, + required this.isSecondInputInRange, + required this.isAllInputsValid, + required this.firstInputMaximumValue, + required this.secondInputMinimumValue, + required this.label, + this.secondInputMaximumValue, + this.firstInputController, + this.secondInputController, + this.secondInputFocusNode, + this.firstInputMinimumValue, + }) : super(child: child); + + final TextEditingController? firstInputController; + final TextEditingController? secondInputController; + + final FocusNode firstInputFocusNode; + final FocusNode? secondInputFocusNode; + + final NumberPadWidgetType type; + + final double? firstInputMinimumValue; + final double firstInputMaximumValue; + final double secondInputMinimumValue; + final double? secondInputMaximumValue; + + final NumberFormat formatter; + + final String currency; + + final bool Function() isAllInputsValid; + final bool Function() isFirstInputInRange; + final bool Function() isSecondInputInRange; + + final TextEditingController? Function() focusedInput; + + final NumberPadLabel label; + + static _NumberPadProvider? of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_NumberPadProvider>(); + + @override + bool updateShouldNotify(InheritedWidget oldWidget) => true; +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_text_title.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_text_title.dart new file mode 100644 index 000000000..64e86099f --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_text_title.dart @@ -0,0 +1,53 @@ +part of 'number_pad.dart'; + +class _NumberPadSingleTextTitle extends StatelessWidget { + const _NumberPadSingleTextTitle({ + required this.title, + this.dialogDescription, + this.leading, + }); + + final String title; + + /// Message of the [InfoIconButton] Widget. + final String? dialogDescription; + + /// The widget on the extreme left of this [_NumberPadSingleTextTitle]. + final Widget? leading; + + @override + Widget build(BuildContext context) => Container( + color: context.theme.colors.secondary, + padding: const EdgeInsets.symmetric(vertical: ThemeProvider.margin16), + child: Stack( + alignment: Alignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(left: ThemeProvider.margin16), + child: Align( + alignment: Alignment.centerLeft, + child: leading ?? const SizedBox.shrink(), + ), + ), + Text( + title, + style: context.theme.textStyle(textStyle: TextStyles.subheading), + ), + Padding( + padding: const EdgeInsets.only(right: ThemeProvider.margin16), + child: Align( + alignment: Alignment.centerRight, + child: dialogDescription != null + ? InfoIconButton( + dialogTitle: title, + dialogDescription: dialogDescription, + positiveActionLabel: + _NumberPadProvider.of(context)!.label.actionOK, + ) + : const SizedBox.shrink(), + ), + ) + ], + ), + ); +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_textfield.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_textfield.dart new file mode 100644 index 000000000..2110507b2 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_single_textfield.dart @@ -0,0 +1,104 @@ +part of 'number_pad.dart'; + +class _NumberPadSingleTextField extends StatelessWidget { + const _NumberPadSingleTextField({ + required this.title, + this.dialogDescription, + this.leading, + }); + + final String title; + final String? dialogDescription; + final Widget? leading; + + Size getTextSize( + String text, + TextStyle style, + BuildContext context, { + double? textScaleFactor, + }) => + (TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textScaleFactor: textScaleFactor ?? 1, + textDirection: ui.TextDirection.ltr, + )..layout()) + .size; + + @override + Widget build(BuildContext context) { + const double margin = ThemeProvider.margin24; + + final _NumberPadProvider? numPadProvider = _NumberPadProvider.of(context); + final ExchangeController? exchangeProvider = ExchangeNotifier.of(context); + + final Size labelSize = getTextSize( + exchangeProvider?.primaryCurrency.currencyType ?? + numPadProvider?.currency ?? + '', + TextStyles.headlineNormal, + context, + ); + return Column( + children: [ + title.isEmpty + ? const SizedBox.shrink() + : _NumberPadSingleTextTitle( + title: title, + dialogDescription: dialogDescription, + leading: leading, + ), + exchangeProvider != null + ? Padding( + padding: const EdgeInsets.only(top: ThemeProvider.margin16), + child: CurrencySwitcher( + currencyDetail: + ExchangeNotifier.of(context)!.secondaryCurrency, + onTap: () => ExchangeNotifier.of(context)!.swap(), + ), + ) + : const SizedBox.shrink(), + Row( + children: [ + Expanded( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Size textSize = getTextSize( + numPadProvider?.firstInputController?.text ?? '', + TextStyles.display1, + context, + ); + double padding = margin + labelSize.width; + if (textSize.width + padding > constraints.maxWidth) { + padding = margin; + } + + return numPadProvider != null + ? Padding( + padding: EdgeInsets.only(left: padding), + child: _NumberPadTextField( + controller: numPadProvider.firstInputController, + textStyle: TextStyles.display1, + focusNode: numPadProvider.firstInputFocusNode, + inputValidator: numPadProvider.isFirstInputInRange, + textAlign: TextAlign.center, + suffixIcon: Text( + exchangeProvider?.primaryCurrency.currencyType ?? + numPadProvider.currency, + style: context.theme.textStyle( + textStyle: TextStyles.headlineNormal, + color: context.theme.colors.disabled, + ), + ), + ), + ) + : const SizedBox.shrink(); + }, + ), + ), + ], + ), + ], + ); + } +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/number_pad_text_field.dart b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_text_field.dart new file mode 100644 index 000000000..2bf0e2233 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/number_pad_text_field.dart @@ -0,0 +1,102 @@ +part of 'number_pad.dart'; + +class _NumberPadTextField extends StatefulWidget { + const _NumberPadTextField({ + required this.textStyle, + required this.inputValidator, + this.controller, + this.focusNode, + this.textAlign, + this.label = '', + this.suffixIcon, + }); + + final Widget? suffixIcon; + final TextEditingController? controller; + final TextStyle textStyle; + final FocusNode? focusNode; + final bool Function() inputValidator; + final TextAlign? textAlign; + final String label; + + @override + _NumberPadTextFieldState createState() => _NumberPadTextFieldState(); +} + +class _NumberPadTextFieldState extends State<_NumberPadTextField> { + Color? _labelColor; + + @override + void initState() { + super.initState(); + + _labelColor = context.theme.colors.disabled; + + widget.focusNode?.addListener(_onFocusChanged); + } + + @override + Widget build(BuildContext context) => TextField( + controller: widget.controller, + focusNode: widget.focusNode, + style: widget.inputValidator.call() + ? context.theme.textStyle(textStyle: widget.textStyle) + : context.theme.textStyle( + textStyle: widget.textStyle, + color: context.theme.colors.coral, + ), + decoration: widget.label.isEmpty + ? InputDecoration( + border: InputBorder.none, + suffixIcon: widget.suffixIcon != null + ? Padding( + padding: const EdgeInsets.only( + right: ThemeProvider.margin16), + child: widget.suffixIcon, + ) + : null, + ) + : InputDecoration( + suffixIcon: widget.suffixIcon != null + ? Padding( + padding: const EdgeInsets.only( + right: ThemeProvider.margin16), + child: widget.suffixIcon, + ) + : null, + border: const OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: context.theme.colors.blue, + ), + ), + labelText: widget.label, + labelStyle: context.theme.textStyle( + textStyle: widget.textStyle, + color: _labelColor, + ), + ), + textAlign: widget.textAlign ?? TextAlign.start, + readOnly: true, + showCursor: true, + cursorColor: context.theme.colors.prominent, + enableInteractiveSelection: false, + autofocus: true, + ); + + void _onFocusChanged() { + if (widget.focusNode?.hasFocus ?? false) { + if (widget.controller?.text == noInput) { + widget.controller?.clear(); + } + } else if (widget.controller?.text.isEmpty ?? false) { + widget.controller?.text = noInput; + } + + if (widget.label.isNotEmpty) { + setState(() => _labelColor = (widget.focusNode?.hasFocus ?? false) + ? context.theme.colors.blue + : context.theme.colors.disabled); + } + } +} diff --git a/packages/deriv_ui/lib/components/numpad/widgets/widgets.dart b/packages/deriv_ui/lib/components/numpad/widgets/widgets.dart new file mode 100644 index 000000000..3f0955129 --- /dev/null +++ b/packages/deriv_ui/lib/components/numpad/widgets/widgets.dart @@ -0,0 +1 @@ +export 'number_pad.dart'; diff --git a/packages/deriv_ui/pubspec.yaml b/packages/deriv_ui/pubspec.yaml index 335f161e5..c2ea2d09e 100644 --- a/packages/deriv_ui/pubspec.yaml +++ b/packages/deriv_ui/pubspec.yaml @@ -27,10 +27,13 @@ dependencies: flutter_svg: ^2.0.7 intl: ^0.18.0 + decimal: ^2.3.2 + cupertino_icons: ^1.0.2 dev_dependencies: flutter_test: sdk: flutter + intl_utils: ^2.0.0 flutter_lints: ^2.0.0 mocktail: ^0.3.0 @@ -40,6 +43,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: generate: true + uses-material-design: true # To add assets to your package, add an assets section, like this: assets: - assets/icons/ diff --git a/packages/deriv_ui/test/components/numpad/widgets/number_pad_test.dart b/packages/deriv_ui/test/components/numpad/widgets/number_pad_test.dart new file mode 100644 index 000000000..20e46c5dd --- /dev/null +++ b/packages/deriv_ui/test/components/numpad/widgets/number_pad_test.dart @@ -0,0 +1,658 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:deriv_ui/deriv_ui.dart'; + +void main() { + group('NumberPad widget test', () { + testWidgets( + 'Appearance of the necessary titles and inputs and keys of single input', + (WidgetTester tester) async { + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + const String currency = 'USDT'; + const double minValue = 10; + const double maxValue = 60; + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + currency: currency, + firstInputTitle: 'Trade Amount', + formatter: formatter, + firstInputMinimumValue: minValue, + firstInputMaximumValue: maxValue, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + expect(find.text('Trade Amount'), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(1)); + expect(find.byType(TextButton), findsNWidgets(13)); + expect(find.text(currency), findsOneWidget); + expect( + find.text( + 'Trade Amount between $minValue $currency and $maxValue $currency', + ), + findsOneWidget); + }); + + testWidgets( + 'Appearance of the necessary titles and inputs and keys of double input', + (WidgetTester tester) async { + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + const String currency = 'USDT'; + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.doubleInput, + currency: currency, + firstInputTitle: 'Take Profit', + secondInputTitle: 'Stop Loss', + formatter: formatter, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + expect(find.text('Take Profit'), findsOneWidget); + expect(find.text('Stop Loss'), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.text(currency), findsOneWidget); + expect(find.byType(TextButton), findsNWidgets(13)); + }); + + testWidgets( + 'The initial values should be visible in the UI with correct formatting', + (WidgetTester tester) async { + const double firstInitialValue = 10; + const double secondInitialValue = 20; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.doubleInput, + formatter: formatter, + firstInputInitialValue: firstInitialValue, + secondInputInitialValue: secondInitialValue, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + expect(find.text(formatter.format(firstInitialValue)), findsOneWidget); + expect(find.text(formatter.format(secondInitialValue)), findsOneWidget); + }); + + testWidgets('Show proper message for input less than minimum', + (WidgetTester tester) async { + const double firstInitialValue = 10; + const double firstMinValue = 20; + const String firstTitle = 'first'; + const String currency = 'USDT'; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + currency: currency, + formatter: formatter, + firstInputInitialValue: firstInitialValue, + firstInputMinimumValue: firstMinValue, + firstInputTitle: firstTitle, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + const String message = + '$firstTitle can\'t be less than $firstMinValue $currency'; + expect(find.text(message), findsOneWidget); + }); + + testWidgets('Show proper message for input greater than maximum', + (WidgetTester tester) async { + const String currency = 'USDT'; + const double firstInitialValue = 30; + const double firstMaxValue = 20; + const String firstTitle = 'first'; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + currency: currency, + formatter: formatter, + firstInputInitialValue: firstInitialValue, + firstInputMaximumValue: firstMaxValue, + firstInputTitle: firstTitle, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + const String message = + '$firstTitle can\'t be greater than $firstMaxValue $currency'; + + expect(find.text(message), findsOneWidget); + }); + + testWidgets('onOpen event be called properly', (WidgetTester tester) async { + bool isOpened = false; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + formatter: formatter, + onOpen: () { + isOpened = true; + }, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + expect(isOpened, true); + }); + + testWidgets('keys click event works and updates inputField', + (WidgetTester tester) async { + const String numberToPress = '5'; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + formatter: formatter, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(numberToPress)); + await tester.pump(); + + expect(find.text(numberToPress), findsNWidgets(2)); + }); + + testWidgets('onClose event returns the proper values', + (WidgetTester tester) async { + bool isClosed = false; + late NumberPadData data; + const double firstInitialValue = 20; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + formatter: formatter, + firstInputInitialValue: firstInitialValue, + onClose: (NumberPadWidgetType a, NumberPadCloseType c, + NumberPadData param2) { + isClosed = true; + data = NumberPadData( + firstInputValue: param2.firstInputValue, + secondInputValue: param2.secondInputValue, + ); + }, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pump(); + + expect(find.text('OK'), findsOneWidget); + expect( + tester.widget(find.byType(IgnorePointer).last).ignoring, + false, + ); + + tester.widget(find.byType(TextButton).last).onPressed?.call(); + await tester.pump(); + + expect(isClosed, true); + expect(data.firstInputValue, firstInitialValue); + expect(data.secondInputValue, isNull); + }); + + testWidgets( + 'onClose event returns the proper values when input is not in range', + (WidgetTester tester) async { + bool isClosed = false; + late NumberPadData data; + const double firstInitialValue = 20; + final NumberFormat formatter = NumberFormat('#.00', 'en_US'); + + await tester.pumpWidget( + TestWidget( + NumberPad( + numberPadType: NumberPadWidgetType.singleInput, + formatter: formatter, + firstInputInitialValue: firstInitialValue, + firstInputMinimumValue: 30, + onClose: (NumberPadWidgetType a, NumberPadCloseType c, + NumberPadData param2) { + isClosed = true; + data = NumberPadData( + firstInputValue: param2.firstInputValue, + secondInputValue: param2.secondInputValue, + ); + }, + label: NumberPadLabel( + semanticNumberPadBottomSheetHandle: + 'semanticNumberPadBottomSheetHandle', + warnValueCantBeLessThan: + (Object input, Object minValue, Object currency) => + '$input can\'t be less than $minValue $currency', + warnValueCantBeGreaterThan: + (Object input, Object maxValue, Object currency) => + '$input can\'t be greater than $maxValue $currency', + warnDoubleInputValueCantBeLessThan: (Object input, + Object minValue, Object currency) => + 'Invalid $input. $input can\'t be less than $minValue $currency', + warnDoubleInputValueCantBeGreaterThan: (Object input, + Object maxValue, Object currency) => + 'Invalid $input. $input can\'t be greater than $maxValue $currency', + warnValueShouldBeInRange: (Object input, Object minValue, + Object currency, Object maxValue) => + '$input between $minValue $currency and $maxValue $currency', + actionOK: 'OK', + ), + ), + ), + ); + + await tester.idle(); + await tester.pump(); + + expect(find.text('OK'), findsOneWidget); + expect( + tester.widget(find.byType(IgnorePointer).last).ignoring, + true, + ); + + tester.widget(find.byType(TextButton).last).onPressed?.call(); + + await tester.pump(); + + expect(isClosed, true); + expect(data.firstInputValue, isNull); + expect(data.secondInputValue, isNull); + }); + }); + + group('NumberPad with exchange currency', () { + late ExchangeRateModel mockExchangeRate; + late Stream mockExchangeStream; + setUpAll(() { + mockExchangeRate = ExchangeRateModel( + baseCurrency: 'BTC', + targetCurrency: 'USD', + exchangeRate: 42800, + ); + mockExchangeStream = + StreamController.broadcast().stream; + }); + + testWidgets('should render exchange switcher, numpad, and title', + (WidgetTester widgetTester) async { + await widgetTester.pumpWidget( + TestWidget( + NumberPad.withCurrencyExchanger( + primaryCurrency: CurrencyDetail(0.123, 'BTC'), + exchangeRatesStream: mockExchangeStream, + initialExchangeRate: mockExchangeRate, + title: 'Amount', + label: NumberPadLabel( + actionOK: 'OK', + ), + ), + ), + ); + + expect(find.text('Amount'), findsOneWidget); + expect(find.byType(CurrencySwitcher), findsOneWidget); + expect(find.byType(TextButton), findsNWidgets(13)); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets( + 'should populate the textfield when there is an amount in primaryCurrency. ', + (WidgetTester widgetTester) async { + final CurrencyDetail mockPrimaryCurrency = CurrencyDetail(0.123, 'BTC'); + await widgetTester.pumpWidget( + TestWidget( + NumberPad.withCurrencyExchanger( + primaryCurrency: mockPrimaryCurrency, + exchangeRatesStream: mockExchangeStream, + initialExchangeRate: mockExchangeRate, + title: 'Amount', + label: NumberPadLabel( + actionOK: 'OK', + ), + ), + ), + ); + + final TextField textField = + find.byType(TextField).evaluate().first.widget as TextField; + + expect(textField.controller!.text, mockPrimaryCurrency.displayAmount); + + expect(find.text('5264.40 '), findsOneWidget); + }); + + testWidgets( + 'should render the exchanged amount in USD in UI when user inputs amount in BTC from the keypad.', + (WidgetTester widgetTester) async { + final CurrencyDetail mockPrimaryCurrency = CurrencyDetail(0, 'BTC'); + await widgetTester.pumpWidget( + TestWidget( + NumberPad.withCurrencyExchanger( + primaryCurrency: mockPrimaryCurrency, + exchangeRatesStream: mockExchangeStream, + initialExchangeRate: mockExchangeRate, + title: 'Amount', + label: NumberPadLabel( + actionOK: 'OK', + ), + ), + ), + ); + + // Inputted 0 from the keypad + await widgetTester.tap(find.text('0')); + await widgetTester.pumpAndSettle(); + + // Inputted . from the keypad + await widgetTester.tap(find.text('.')); + await widgetTester.pumpAndSettle(); + + // Inputted 3 from the keypad + await widgetTester.tap(find.text('3')); + await widgetTester.pumpAndSettle(); + + // Inputted 5 from the keypad + await widgetTester.tap(find.text('5')); + await widgetTester.pumpAndSettle(); + + // Inputted 5 from the keypad + await widgetTester.tap(find.text('5')); + await widgetTester.pumpAndSettle(); + + expect(find.text('15194.00 '), findsOneWidget); + }); + + testWidgets( + 'should switch amount from currency switcher to textfield and vice versa when currency Switcher is pressed.', + (WidgetTester widgetTester) async { + final CurrencyDetail mockPrimaryCurrency = CurrencyDetail(0.123, 'BTC'); + + await widgetTester.pumpWidget( + TestWidget( + NumberPad.withCurrencyExchanger( + primaryCurrency: mockPrimaryCurrency, + exchangeRatesStream: mockExchangeStream, + initialExchangeRate: mockExchangeRate, + title: 'Amount', + label: NumberPadLabel( + actionOK: 'OK', + ), + ), + ), + ); + + await widgetTester.tap(find.text('5264.40 ')); + await widgetTester.pumpAndSettle(); + + final TextField textField = + find.byType(TextField).evaluate().first.widget as TextField; + + expect(textField.controller!.text, '5264.40'); + expect(find.text('0.12300000 '), findsOneWidget); + }, + ); + + testWidgets( + 'should render the exchanged amount in BTC in UI when user inputs amount in USD from the keypad after it is switched.', + (WidgetTester widgetTester) async { + final CurrencyDetail mockPrimaryCurrency = CurrencyDetail(0, 'BTC'); + + await widgetTester.pumpWidget( + TestWidget( + NumberPad.withCurrencyExchanger( + primaryCurrency: mockPrimaryCurrency, + exchangeRatesStream: mockExchangeStream, + initialExchangeRate: mockExchangeRate, + title: 'Amount', + label: NumberPadLabel( + actionOK: 'OK', + ), + ), + ), + ); + + // switch to USD amount. + await widgetTester.tap(find.text('USD')); + await widgetTester.pumpAndSettle(); + + // Inputted 1 from the keypad + await widgetTester.tap(find.text('1')); + await widgetTester.pumpAndSettle(); + + // Inputted 8 from the keypad + await widgetTester.tap(find.text('8')); + await widgetTester.pumpAndSettle(); + + // Inputted 0 from the keypad + await widgetTester.tap(find.text('0')); + await widgetTester.pumpAndSettle(); + + // Inputted 0 from the keypad + await widgetTester.tap(find.text('0')); + await widgetTester.pumpAndSettle(); + + expect(find.text('0.04205607 '), findsOneWidget); + }, + ); + }); +} + +class TestWidget extends StatelessWidget { + const TestWidget(this.numberInput, {Key? key}) : super(key: key); + + final NumberPad numberInput; + + @override + Widget build(BuildContext context) => MaterialApp( + home: Scaffold( + bottomSheet: numberInput, + ), + ); +}