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,
+ ),
+ );
+}