diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 61d600b3c..6b9f9e2d7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -91,7 +91,7 @@ "addressBalanceDescription": "Received payments totalling", "addressCopied": "Address copied!", "addressList": "Address list", - "addTimelockLabel": "Add Timelock (Optional)", + "addAdvancedSettings": "Add Advanced Settings (Optional)", "advancedSettings": "Advanced Settings", "amount": "Amount", "authenticateWithBiometrics": "Authenticate with biometrics", @@ -232,6 +232,7 @@ "messageSigning": "Message Signing", "messageSigning01": "Prove the ownership of your address by adding your signature to a message.", "messageToBeSigned": "Message to be signed", + "metadata": "Metadata", "mined": "Mined", "minerFeeHint": "By default, 'Absolute fee' is selected.\nTo set a custom weighted fee, you need to select 'Weighted'. \nThe Weighted fee is automatically calculated by the wallet considering the network congestion and transaction weight multiplied by the value selected as custom.", "minerFeeInputHint": "Input the miner fee", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 31d308683..b127e089a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -91,7 +91,7 @@ "addressBalanceDescription": "Recibió pagos por un total de", "addressCopied": "¡Dirección copiada!", "addressList": "Lista de direcciones", - "addTimelockLabel": "Añadir Timelock (Opcional)", + "addAdvancedSettings": "Añadir Opciones avanzadas (Opcional)", "advancedSettings": "Configuración avanzada", "amount": "Cantidad", "authenticateWithBiometrics": "Autenticación biométrica", @@ -230,6 +230,7 @@ "messageSigning": "Firma de mensajes", "messageSigning01": "Demuestre la propiedad de su dirección añadiendo su firma a un mensaje.", "messageToBeSigned": "Mensaje a firmar", + "metadata": "Metadatos", "mined": "Minada", "minerFeeHint": "Por defecto, la tarifa Absoluta está seleccionada. La comisión ponderada la calcula automáticamente el monedero teniendo en cuenta la congestión de la red y el peso de la transacción multiplicado por el valor seleccionado como personalizado.", "minerFeeInputHint": "Introduzca la tasa minera", diff --git a/lib/util/extensions/string_extensions.dart b/lib/util/extensions/string_extensions.dart index 8ffa70e24..cc8c8fb90 100644 --- a/lib/util/extensions/string_extensions.dart +++ b/lib/util/extensions/string_extensions.dart @@ -30,4 +30,14 @@ extension StringExtension on String { .join(' '); bool toBoolean() => this == 'true' || this == 'True'; + + bool isHexString() { + final hexRegex = RegExp(r'^[a-fA-F0-9]+$'); + + if (this.startsWith("0x")) { + return hexRegex.hasMatch(this.substring(2)); + } else { + return hexRegex.hasMatch(this); + } + } } diff --git a/lib/util/metadata_utils.dart b/lib/util/metadata_utils.dart new file mode 100644 index 000000000..9959f143b --- /dev/null +++ b/lib/util/metadata_utils.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +import 'package:witnet/schema.dart'; + +String? metadataFromOutputs( + List outputs, { + List trueOutputAddresses = const [], + List changeOutputAddresses = const [], +}) { + if (outputs.isEmpty) { + return null; + } + + Iterable candidates = + outputs.where((output) => output.value.toInt() == 1); + + if (trueOutputAddresses.isNotEmpty || changeOutputAddresses.isNotEmpty) { + final excluded = { + ...trueOutputAddresses, + ...changeOutputAddresses, + }; + final filtered = candidates + .where((output) => !excluded.contains(output.pkh.address)) + .toList(); + if (filtered.isNotEmpty) { + candidates = filtered; + } + } + + if (candidates.isEmpty) { + return null; + } + + return _decodeMetadataOutput(candidates.first); +} + +String? _decodeMetadataOutput(ValueTransferOutput output) { + final bytes = List.from(output.pkh.hash); + int end = bytes.length; + while (end > 0 && bytes[end - 1] == 0) { + end--; + } + if (end == 0) { + return null; + } + + final trimmed = bytes.sublist(0, end); + String decoded; + try { + decoded = utf8.decode(trimmed, allowMalformed: true); + } catch (_) { + return '0x${output.pkh.hex}'; + } + + if (_isPrintable(decoded)) { + return decoded; + } + + return '0x${output.pkh.hex}'; +} + +bool _isPrintable(String value) { + if (value.contains('\uFFFD')) { + return false; + } + for (final rune in value.runes) { + if (rune == 0x09 || rune == 0x0A || rune == 0x0D) { + continue; + } + if (rune < 0x20 || rune == 0x7F) { + return false; + } + } + return true; +} diff --git a/lib/util/transactions_list/transaction_utils.dart b/lib/util/transactions_list/transaction_utils.dart index f487cc4cd..0149cb24a 100644 --- a/lib/util/transactions_list/transaction_utils.dart +++ b/lib/util/transactions_list/transaction_utils.dart @@ -5,6 +5,7 @@ import 'package:my_wit_wallet/shared/locator.dart'; import 'package:my_wit_wallet/theme/colors.dart'; import 'package:my_wit_wallet/theme/extended_theme.dart'; import 'package:my_wit_wallet/util/get_localization.dart'; +import 'package:my_wit_wallet/util/metadata_utils.dart'; import 'package:my_wit_wallet/util/storage/database/account.dart'; import 'package:my_wit_wallet/util/storage/database/adapters/transaction_adapter.dart'; import 'package:my_wit_wallet/util/storage/database/wallet.dart'; @@ -308,6 +309,14 @@ class TransactionUtils { } } + String? metadata() { + if (vti.type != TransactionType.value_transfer || vti.vtt == null) { + return null; + } + + return metadataFromOutputs(vti.vtt!.outputs); + } + Widget buildTransactionValue(label, context) { final theme = Theme.of(context); final extendedTheme = theme.extension()!; diff --git a/lib/widgets/inputs/input_metadata.dart b/lib/widgets/inputs/input_metadata.dart new file mode 100644 index 000000000..2f1c8695c --- /dev/null +++ b/lib/widgets/inputs/input_metadata.dart @@ -0,0 +1,115 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:my_wit_wallet/widgets/inputs/input_text.dart'; +import 'package:my_wit_wallet/widgets/suffix_icon_button.dart'; +import 'package:my_wit_wallet/widgets/witnet/transactions/value_transfer/create_dialog_box/qr_scanner.dart'; + +import 'package:my_wit_wallet/theme/extended_theme.dart'; +import 'package:my_wit_wallet/util/get_localization.dart'; +import 'package:my_wit_wallet/util/storage/scanned_content.dart'; + +class InputMetadata extends InputText { + InputMetadata({ + required super.focusNode, + required super.styledTextController, + super.prefixIcon, + super.errorText, + super.validator, + super.hint, + super.keyboardType, + super.obscureText = false, + this.route, + super.onChanged, + super.onEditingComplete, + super.onFieldSubmitted, + super.onTapOutside, + super.onTap, + super.onSuffixTap, + super.inputFormatters, + super.decoration, + super.maxLines = 1, + this.setMetadataCallback, + }); + + final String? route; + final void Function(String, {bool? validate})? setMetadataCallback; + @override + _InputMetadataState createState() => _InputMetadataState(); +} + +class _InputMetadataState extends State { + FocusNode _scanQrFocusNode = FocusNode(); + bool isScanQrFocused = false; + ScannedContent scannedContent = ScannedContent(); + TextSelection? lastSelection; + + @override + void initState() { + super.initState(); + if (scannedContent.scannedContent != null) { + _handleQrMetadataResults(scannedContent.scannedContent!); + } + widget.focusNode.addListener(widget.onFocusChange); + _scanQrFocusNode.addListener(_handleQrFocus); + } + + @override + void dispose() { + super.dispose(); + widget.focusNode.removeListener(widget.onFocusChange); + _scanQrFocusNode.removeListener(_handleQrFocus); + } + + _handleQrFocus() { + setState(() { + isScanQrFocused = _scanQrFocusNode.hasFocus; + }); + } + + _handleQrMetadataResults(String value) { + widget.styledTextController.text = value; + widget.setMetadataCallback!(value); + } + + Widget build(BuildContext context) { + final theme = Theme.of(context); + final extendedTheme = theme.extension()!; + + widget.styledTextController.setStyle( + extendedTheme.monoLargeText! + .copyWith(color: theme.textTheme.bodyMedium!.color), + extendedTheme.monoLargeText!.copyWith(color: Colors.black), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + children: [ + widget.buildInput( + context: context, + decoration: widget.decoration ?? + InputDecoration( + hintStyle: extendedTheme.monoLargeText!.copyWith( + color: theme.inputDecorationTheme.hintStyle!.color), + hintText: localization.metadata, + suffixIcon: !Platform.isWindows && !Platform.isLinux + ? Semantics( + label: localization.scanQrCodeLabel, + child: SuffixIcon( + onPressed: () => { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => QrScanner( + currentRoute: widget.route!, + onChanged: (_value) => {}))) + }, + icon: FontAwesomeIcons.qrcode, + isFocus: isScanQrFocused, + focusNode: _scanQrFocusNode)) + : null, + errorText: widget.errorText, + )), + ]); + } +} diff --git a/lib/widgets/transaction_details.dart b/lib/widgets/transaction_details.dart index 9e3b5f841..30db24230 100644 --- a/lib/widgets/transaction_details.dart +++ b/lib/widgets/transaction_details.dart @@ -237,6 +237,7 @@ class TransactionDetails extends StatelessWidget { TransactionUtils transactionUtils = TransactionUtils(vti: transaction); String label = transactionUtils.getLabel(); String? timelock = transactionUtils.timelock(); + String? metadata = transactionUtils.metadata(); return ClosableView( closeSetting: goToList, @@ -302,7 +303,14 @@ class TransactionDetails extends StatelessWidget { label: localization.total, text: transactionUtils.getTransactionValue().amount, isContentImportant: true, - isLastItem: timelock == null), + isLastItem: timelock == null && metadata == null), + if (metadata != null) + InfoCopy( + label: localization.metadata, + text: metadata.cropMiddle(18), + infoToCopy: metadata, + isContentImportant: true, + isLastItem: timelock == null), timelock != null ? InfoElement( label: localization.timelock, diff --git a/lib/widgets/validations/metadata_input.dart b/lib/widgets/validations/metadata_input.dart new file mode 100644 index 000000000..e634202ca --- /dev/null +++ b/lib/widgets/validations/metadata_input.dart @@ -0,0 +1,46 @@ +import 'package:formz/formz.dart'; +import 'package:my_wit_wallet/util/extensions/string_extensions.dart'; +import 'package:my_wit_wallet/widgets/validations/validation_utils.dart'; + +// Define input validation errors +enum MetadataInputError { invalidHex, invalidLength } + +Map errorMap = { + MetadataInputError.invalidHex: 'Invalid hex metadata', + MetadataInputError.invalidLength: 'Invalid length metadata', +}; + +// Extend FormzInput and provide the input type and error type. +class MetadataInput extends FormzInput { + final bool allowValidation; + // Call super.pure to represent an unmodified form input. + const MetadataInput.pure() + : allowValidation = false, + super.pure(''); + + // Call super.dirty to represent a modified form input. + const MetadataInput.dirty({String value = '', this.allowValidation = false}) + : super.dirty(value); + + // Override validator to handle validating a given input value. + @override + String? validator(String? value) { + final _validationUtils = ValidationUtils(errorMap: errorMap); + + if (!this.allowValidation) { + return null; + } + + if (value != null) { + if (!value.isHexString()) { + return _validationUtils.getErrorText(MetadataInputError.invalidHex); + } + + if (!((value.startsWith("0x") && (value.substring(2).length == 20 * 2)) || + (value.length == 20 * 2))) { + return _validationUtils.getErrorText(MetadataInputError.invalidLength); + } + } + return null; + } +} diff --git a/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/01_recipient_step.dart b/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/01_recipient_step.dart index 2a603210f..d35ddaef5 100644 --- a/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/01_recipient_step.dart +++ b/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/01_recipient_step.dart @@ -7,11 +7,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:my_wit_wallet/util/min_amount_unstake.dart'; import 'package:my_wit_wallet/util/showTxConnectionError.dart'; +import 'package:my_wit_wallet/util/metadata_utils.dart'; +import 'package:my_wit_wallet/util/storage/database/adapters/transaction_adapter.dart'; import 'package:my_wit_wallet/util/storage/database/wallet_storage.dart'; import 'package:my_wit_wallet/util/storage/scanned_content.dart'; import 'package:my_wit_wallet/widgets/buttons/icon_btn.dart'; import 'package:my_wit_wallet/widgets/inputs/input_address.dart'; import 'package:my_wit_wallet/widgets/inputs/input_authorization.dart'; +import 'package:my_wit_wallet/widgets/inputs/input_metadata.dart'; import 'package:my_wit_wallet/widgets/inputs/input_slider.dart'; import 'package:my_wit_wallet/widgets/labeled_form_entry.dart'; import 'package:my_wit_wallet/widgets/layouts/send_transaction_layout.dart'; @@ -19,6 +22,7 @@ import 'package:my_wit_wallet/widgets/select.dart'; import 'package:my_wit_wallet/widgets/snack_bars.dart'; import 'package:my_wit_wallet/widgets/validations/address_input.dart'; import 'package:my_wit_wallet/widgets/validations/authorization_input.dart'; +import 'package:my_wit_wallet/widgets/validations/metadata_input.dart'; import 'package:my_wit_wallet/widgets/validations/validation_utils.dart'; import 'package:my_wit_wallet/widgets/validations/tx_amount_input.dart'; import 'package:my_wit_wallet/widgets/withdrawal_address.dart'; @@ -35,6 +39,7 @@ import 'package:my_wit_wallet/util/extensions/text_input_formatter.dart'; import 'package:my_wit_wallet/util/get_localization.dart'; import 'package:witnet/utils.dart'; import 'package:my_wit_wallet/util/storage/database/account.dart'; +import 'package:witnet/data_structures.dart'; class RecipientStep extends StatefulWidget { final Function nextAction; @@ -67,6 +72,7 @@ class RecipientStepState extends State AddressInput _address = AddressInput.pure(); TxAmountInput _amount = TxAmountInput.pure(); AuthorizationInput _authorization = AuthorizationInput.pure(); + MetadataInput _metadata = MetadataInput.pure(); List get validatorAddressesUsedInStakes => List.from(widget.walletStorage.currentWallet .stakesValidators() @@ -79,6 +85,9 @@ class RecipientStepState extends State final _addressFocusNode = FocusNode(); final _authorizationController = StyledTextController(); final _authorizationFocusNode = FocusNode(); + final _metadataController = StyledTextController(); + final _metadataFocusNode = FocusNode(); + bool _connectionError = false; String? errorMessage; @@ -86,7 +95,7 @@ class RecipientStepState extends State bool isCopyAddressFocused = false; ValidationUtils validationUtils = ValidationUtils(); List _formFocusElements() => isVttTransaction - ? [_addressFocusNode, _amountFocusNode] + ? [_addressFocusNode, _amountFocusNode, _metadataFocusNode] : [_addressFocusNode, _amountFocusNode, _authorizationFocusNode]; ValueTransferOutput? ongoingOutput; TransactionBloc get transactionBloc => @@ -145,6 +154,8 @@ class RecipientStepState extends State _amountFocusNode.dispose(); _authorizationController.dispose(); _authorizationFocusNode.dispose(); + _metadataController.dispose(); + _metadataFocusNode.dispose(); super.dispose(); } @@ -184,6 +195,11 @@ class RecipientStepState extends State if (force) { setAddress(_address.value, validate: true); setAmount(_amount.value, validate: true); + + if (_metadata.value.isNotEmpty) { + setMetadata(_metadata.value, validate: true); + } + setMetadata(_metadata.value, validate: true); if (showAuthorization) { setAuthorization(_authorization.value, validate: true); } @@ -243,6 +259,9 @@ class RecipientStepState extends State transactionBloc.state.transaction.get(widget.transactionType) != null ? transactionBloc.authorizationString : null; + String? savedMetadata = isVttTransaction + ? _getSavedMetadata(transactionBloc.state.transaction) + : null; if (savedAddress != null) { _addressController.text = savedAddress; @@ -259,6 +278,12 @@ class RecipientStepState extends State setAuthorization(savedAuthorization); } + if (savedMetadata != null) { + _metadataController.text = savedMetadata; + setMetadata(savedMetadata, validate: false); + showAdvancedSettings = true; + } + transactionBloc.add(ResetTransactionEvent()); } @@ -278,6 +303,23 @@ class RecipientStepState extends State setAmount(_amountController.text, validate: false); } + setMetadata(String value, {bool? validate}) { + _metadata = MetadataInput.dirty( + value: value, + allowValidation: + validate ?? validationUtils.isFormUnFocus(_formFocusElements()), + ); + } + + String? _getSavedMetadata(BuildTransaction transaction) { + final vtTransaction = transaction.vtTransaction; + if (vtTransaction == null) { + return null; + } + + return metadataFromOutputs(vtTransaction.body.outputs); + } + void nextAction() { final theme = Theme.of(context); if (_connectionError) { @@ -327,6 +369,14 @@ class RecipientStepState extends State 'time_lock': timelockSet ? dateTimeToTimelock(calendarValue) : 0, }), merge: true)); + + if (_metadata.value.isNotEmpty) { + transactionBloc.add(AddValueTransferOutputEvent( + merge: false, + currentWallet: widget.walletStorage.currentWallet, + output: createMetadataOutput(_metadata.value), + )); + } } } } @@ -372,9 +422,9 @@ class RecipientStepState extends State mainAxisAlignment: MainAxisAlignment.start, children: [ IconBtn( - label: localization.addTimelockLabel, + label: localization.addAdvancedSettings, padding: EdgeInsets.all(0), - text: localization.addTimelockLabel, + text: localization.addAdvancedSettings, onPressed: () { setState(() { showAdvancedSettings = !showAdvancedSettings; @@ -391,13 +441,25 @@ class RecipientStepState extends State ], ), showAdvancedSettings - ? Padding( - padding: EdgeInsets.only(left: 8, right: 8), - child: timelockInput.TimelockInput( - timelockSet: timelockSet, - onSelectedDate: _setTimeLock, - onClearTimelock: _clearTimeLock, - calendarValue: calendarValue)) + ? Column( + children: [ + Padding( + padding: EdgeInsets.only(left: 8, right: 8), + child: timelockInput.TimelockInput( + timelockSet: timelockSet, + onSelectedDate: _setTimeLock, + onClearTimelock: _clearTimeLock, + calendarValue: calendarValue, + ), + ), + Column( + children: [ + Padding(padding: EdgeInsets.only(top: 16)), + ..._buildMetadataInput(theme), + ], + ), + ], + ) : Container() ], )); @@ -628,6 +690,35 @@ class RecipientStepState extends State : _buildReceiverAddressInput(theme); } + List _buildMetadataInput(ThemeData theme) { + return [ + LabeledFormEntry( + label: localization.metadata, + formEntry: InputMetadata( + route: widget.routeName, + errorText: _metadata.error, + styledTextController: _metadataController, + focusNode: _metadataFocusNode, + keyboardType: TextInputType.text, + inputFormatters: [], + onChanged: (String value) { + setMetadata(value); + }, + onFieldSubmitted: (String value) { + _metadataFocusNode.requestFocus(); + }, + onTap: () { + _metadataFocusNode.requestFocus(); + }, + onTapOutside: (event) { + _metadataFocusNode.unfocus(); + }, + setMetadataCallback: setMetadata, + ), + ), + ]; + } + _buildForm(BuildContext context, ThemeData theme) { final theme = Theme.of(context); _addressFocusNode.addListener(() => validateForm()); diff --git a/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/03_review_step.dart b/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/03_review_step.dart index 7b9149452..f9c86b858 100644 --- a/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/03_review_step.dart +++ b/lib/widgets/witnet/transactions/value_transfer/create_dialog_box/vtt_builder/03_review_step.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:my_wit_wallet/util/allow_biometrics.dart'; import 'package:my_wit_wallet/util/extensions/string_extensions.dart'; +import 'package:my_wit_wallet/util/metadata_utils.dart'; import 'package:my_wit_wallet/util/storage/database/adapters/transaction_adapter.dart'; import 'package:my_wit_wallet/widgets/layouts/send_transaction_layout.dart'; import 'package:my_wit_wallet/widgets/witnet/transactions/value_transfer/modals/general_error_tx_modal.dart'; @@ -102,6 +103,18 @@ class ReviewStepState extends State return '${state.transaction.getAmount(state.transactionType)} ${WIT_UNIT[WitUnit.Wit]}'; } + String? _getMetadata(TransactionState state) { + if (!isVttTransaction) { + return null; + } + final vtTransaction = state.transaction.vtTransaction; + if (vtTransaction == null) { + return null; + } + + return metadataFromOutputs(vtTransaction.body.outputs); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -163,6 +176,7 @@ class ReviewStepState extends State state.transaction.hasTimelock(state.transactionType); String address = state.transaction.getRecipient(state.transactionType); + String? metadata = _getMetadata(state); return Padding( padding: EdgeInsets.only(left: 8, right: 8), child: Column( @@ -188,22 +202,32 @@ class ReviewStepState extends State SizedBox( height: 16, ), - if (showFeeInfo) ..._buildTransactionFeeInfo(context), + if (showFeeInfo) + ..._buildTransactionFeeInfo(context, + addBottomSpacing: metadata == null), + if (metadata != null) + InfoCopy( + infoToCopy: metadata, + label: localization.metadata, + text: metadata.cropMiddle(18), + isLastItem: true), + if (metadata != null) SizedBox(height: 16), ])); }, )); } } -List _buildTransactionFeeInfo(BuildContext context) { +List _buildTransactionFeeInfo(BuildContext context, + {bool addBottomSpacing = true}) { int fee = BlocProvider.of(context).getFee(); return [ InfoElement( label: localization.fee, - isLastItem: true, + isLastItem: addBottomSpacing, text: '${fee.standardizeWitUnits().formatWithCommaSeparator()} ${WIT_UNIT[WitUnit.Wit]}'), - SizedBox(height: 16), + if (addBottomSpacing) SizedBox(height: 16), ]; } diff --git a/test/util/extensions/is_hex_string_test.dart b/test/util/extensions/is_hex_string_test.dart new file mode 100644 index 000000000..59b7a934a --- /dev/null +++ b/test/util/extensions/is_hex_string_test.dart @@ -0,0 +1,24 @@ +import 'package:my_wit_wallet/util/extensions/string_extensions.dart'; +import 'package:test/test.dart'; + +void main() { + group( + 'String Extensions isHexString', + () => { + test( + 'should return true for valid hex strings', + () => { + expect('0x1a2b3c'.isHexString(), true), + expect('1A2B3C'.isHexString(), true), + expect('abcdef'.isHexString(), true), + expect('1234567890'.isHexString(), true), + }), + test( + 'should return false for invalid hex strings', + () => { + expect('0x1z2y3c'.isHexString(), false), + expect('1Z2B3c'.isHexString(), false), + expect('hijklmn'.isHexString(), false), + }), + }); +} diff --git a/test/validations/metadata_input_test.dart b/test/validations/metadata_input_test.dart new file mode 100644 index 000000000..c4c944e89 --- /dev/null +++ b/test/validations/metadata_input_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:my_wit_wallet/widgets/validations/metadata_input.dart'; +import 'package:test/test.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + test('Validation disabled should always return null', () async { + const String metadata = 'notAHexString'; + final MetadataInput input = + MetadataInput.dirty(value: metadata, allowValidation: false); + + expect(input.validator(metadata), null); + }); + + test('Non-hex string returns invalidHex error', () async { + const String metadata = 'ZXY123'; // not a valid hex + final MetadataInput input = + MetadataInput.dirty(value: metadata, allowValidation: true); + + expect(input.validator(metadata), errorMap[MetadataInputError.invalidHex]); + }); + + test('Valid hex but invalid length returns invalidLength error', () async { + const String metadata = '0x1234abcd'; // valid hex but only 8 chars after 0x + final MetadataInput input = + MetadataInput.dirty(value: metadata, allowValidation: true); + + expect( + input.validator(metadata), errorMap[MetadataInputError.invalidLength]); + }); + + test('Valid 40-char hex string passes validation', () async { + const String metadata = + 'aabbccddeeff00112233445566778899aabbccdd'; // 40 hex chars + final MetadataInput input = + MetadataInput.dirty(value: metadata, allowValidation: true); + + expect(input.validator(metadata), null); + }); + + test('Valid 42-char hex string with 0x passes validation', () async { + const String metadata = + '0x' + 'aabbccddeeff00112233445566778899aabbccdd'; // 0x + 40 hex + final MetadataInput input = + MetadataInput.dirty(value: metadata, allowValidation: true); + + expect(input.validator(metadata), null); + }); +}