diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart index 5992ab3ea1a..a3ede15e191 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart @@ -1,29 +1,22 @@ import 'package:catalyst_voices/common/ext/string_ext.dart'; import 'package:catalyst_voices/widgets/text_field/token_field.dart'; import 'package:catalyst_voices/widgets/text_field/voices_int_field.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_text_field.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; class DocumentTokenValueWidget extends StatefulWidget { - final DocumentNodeId id; - final String label; - final int? value; + final DocumentProperty property; final Currency currency; - final Range? range; final bool isEditMode; - final bool isRequired; final ValueChanged onChanged; const DocumentTokenValueWidget({ super.key, - required this.id, - required this.label, - this.value, + required this.property, required this.currency, - this.range, this.isEditMode = false, - this.isRequired = true, required this.onChanged, }); @@ -41,7 +34,7 @@ class _DocumentTokenValueWidgetState extends State { void initState() { super.initState(); - _controller = VoicesIntFieldController(widget.value); + _controller = VoicesIntFieldController(widget.property.value); _controller.addListener(_handleControllerChange); _focusNode = FocusNode(canRequestFocus: widget.isEditMode); } @@ -50,8 +43,8 @@ class _DocumentTokenValueWidgetState extends State { void didUpdateWidget(covariant DocumentTokenValueWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.value != oldWidget.value) { - _controller.value = widget.value; + if (widget.property.value != oldWidget.property.value) { + _controller.value = widget.property.value; } if (widget.isEditMode != oldWidget.isEditMode) { @@ -68,12 +61,16 @@ class _DocumentTokenValueWidgetState extends State { @override Widget build(BuildContext context) { + final schema = widget.property.schema; + final label = schema.title ?? ''; + return TokenField( controller: _controller, focusNode: _focusNode, onFieldSubmitted: _notifyChangeListener, - labelText: widget.label.starred(isEnabled: widget.isRequired), - range: widget.range, + validator: _validate, + labelText: label.starred(isEnabled: schema.isRequired), + range: schema.numRange, currency: widget.currency, showHelper: widget.isEditMode, readOnly: !widget.isEditMode, @@ -97,7 +94,22 @@ class _DocumentTokenValueWidgetState extends State { } void _notifyChangeListener(int? value) { - final change = DocumentChange(nodeId: widget.id, value: value); + final change = DocumentChange( + nodeId: widget.property.schema.nodeId, + value: value, + ); + widget.onChanged(change); } + + VoicesTextFieldValidationResult _validate(int? value, String text) { + final schema = widget.property.schema; + final result = schema.validatePropertyValue(value); + if (result.isValid) { + return const VoicesTextFieldValidationResult.none(); + } else { + final localized = LocalizedDocumentValidationResult.from(result); + return VoicesTextFieldValidationResult.error(localized.message(context)); + } + } } diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/token_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/token_field.dart index cf6ec0c14d9..d7bca6d1e0b 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/token_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/token_field.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/text_field/voices_num_field.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; @@ -8,6 +9,7 @@ class TokenField extends StatelessWidget { final VoicesIntFieldController? controller; final ValueChanged? onFieldSubmitted; final ValueChanged? onStatusChanged; + final VoicesNumFieldValidator? validator; final String? labelText; final String? errorText; final FocusNode? focusNode; @@ -22,6 +24,7 @@ class TokenField extends StatelessWidget { this.controller, required this.onFieldSubmitted, this.onStatusChanged, + this.validator, this.labelText, this.errorText, this.focusNode, @@ -74,9 +77,9 @@ class TokenField extends StatelessWidget { return VoicesTextFieldValidationResult.error(message); } - if (value != null && !(range?.contains(value) ?? true)) { - // Do not append any text - return const VoicesTextFieldValidationResult.error(); + final validator = this.validator; + if (validator != null) { + return validator(value, text); } return const VoicesTextFieldValidationResult.none(); @@ -94,6 +97,8 @@ class _Helper extends StatelessWidget { @override Widget build(BuildContext context) { + // TODO(damian-molinski): Range can accept null as min/max + // meaning they are unconstrained, handle it // TODO(damian-molinski): Refactor text formatting with smarter syntax return Text.rich( TextSpan( diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart index e343118e79d..c286367e95c 100644 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart @@ -244,15 +244,10 @@ class _PropertyBuilder extends StatelessWidget { onChanged: onChanged, ); case TokenValueCardanoADADefinition(): - final castProperty = definition.castProperty(property); return DocumentTokenValueWidget( - id: castProperty.schema.nodeId, - label: castProperty.schema.title ?? '', - value: castProperty.value, + property: definition.castProperty(property), currency: const Currency.ada(), - range: castProperty.schema.numRange, isEditMode: isEditMode, - isRequired: castProperty.schema.isRequired, onChanged: onChanged, ); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index 356c26e6833..9f86a390a13 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -1186,6 +1186,103 @@ "description": "General text. May be used in context of A4000 and $5000" }, "errorValidationTokenNotParsed": "Invalid input. Could not parse parse.", + "@errorValidationTokenNotParsed": { + "description": "A validation error when user enters input which cannot be parsed into token value." + }, + "errorValidationMissingRequiredField": "Please fill this field.", + "@errorValidationMissingRequiredField": { + "description": "A validation error for a missing required form field." + }, + "errorValidationNumFieldBelowMin": "The value should be at least {min}.", + "@errorValidationNumFieldBelowMin": { + "description": "Validation error when the user entered numerical value is smaller than min", + "placeholders": { + "min": { + "type": "int" + } + } + }, + "errorValidationNumFieldAboveMax": "The value should be no bigger than {max}.", + "@errorValidationNumFieldAboveMax": { + "description": "Validation error when the user entered numerical value is bigger than max", + "placeholders": { + "max": { + "type": "int" + } + } + }, + "errorValidationNumFieldOutOfRange": "The value should be between {min} and {max}", + "@errorValidationNumFieldOutOfRange": { + "description": "Validation error when the numerical value is out of allowed range between min and max (both inclusive)", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "errorValidationStringLengthBelowMin": "The text should be at least {min} {min, plural, =0{characters} =1{character} other{characters}}.", + "@errorValidationStringLengthBelowMin": { + "description": "Validation error when the user entered text is shorter than min length", + "placeholders": { + "min": { + "type": "int" + } + } + }, + "errorValidationStringLengthAboveMax": "The text should be no longer than {max} {max, plural, =0{characters} =1{character} other{characters}}.", + "@errorValidationStringLengthAboveMax": { + "description": "Validation error when the user entered text is longer than max length", + "placeholders": { + "max": { + "type": "int" + } + } + }, + "errorValidationStringLengthOutOfRange": "The text should be between {min} and {max} {max, plural, =0{characters} =1{character} other{characters}}.", + "@errorValidationStringLengthOutOfRange": { + "description": "Validation error when the user entered text is out of allowed range between min and max (both inclusive) characters", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "errorValidationListItemsBelowMin": "There should be at least {min} {min, plural, =0{items} =1{item} other{items}}.", + "@errorValidationListItemsBelowMin": { + "description": "Validation error when the user entered less than min items in the list.", + "placeholders": { + "min": { + "type": "int" + } + } + }, + "errorValidationListItemsAboveMax": "There should be no more than {max} {max, plural, =0{items} =1{item} other{items}}.", + "@errorValidationListItemsAboveMax": { + "description": "Validation error when the user entered more than max items in the list.", + "placeholders": { + "max": { + "type": "int" + } + } + }, + "errorValidationListItemsOutOfRange": "There should be between {min} and {max} {max, plural, =0{items} =1{item} other{items}}.", + "@errorValidationListItemsOutOfRange": { + "description": "The number of items in a list/array is out of allowed range between min and max (both inclusive)", + "placeholders": { + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "noTagSelected": "No Tag Selected", "@noTagSelected": { "description": "For example in context of document builder" diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/range.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/range.dart index 249b1e8d3de..2cd79a21509 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/range.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/range/range.dart @@ -1,30 +1,45 @@ import 'package:equatable/equatable.dart'; +/// A numerical range between [min] and [max]. +/// +/// Both [min] and [max] might be unconstrained, +/// this allows to create ranges like: +/// +/// - <-∞, ∞> // from minus infinity to infinity (practically all values). +/// - <0, ∞> // from zero to infinity (negative values are not accepted). +/// - <-∞, 0> // from minus infinity to zero (positive values are not accepted). class Range extends Equatable { /// The minimum range value (inclusive). - final T min; + /// + /// `null` means that the [min] is not constrained. + final T? min; /// The maximum range value (inclusive). - final T max; + /// + /// `null` means that the [max] is not constrained. + final T? max; - const Range({required this.min, required this.max}); + const Range({ + required this.min, + required this.max, + }); /// Creates an [int] [Range] which assumes if /// [min] or [max] are null then they are unconstrained. static Range? optionalIntRangeOf({int? min, int? max}) { - if (min != null && max != null) { - return Range(min: min, max: max); - } else if (max != null) { - return Range(min: 0, max: max); - } else if (min != null) { - return Range(min: min, max: double.maxFinite.toInt()); - } else { + if (min == null && max == null) { return null; } + + return Range(min: min, max: max); } /// Returns true if this range contains the [value], false otherwise. - bool contains(num value) => value >= min && value <= max; + bool contains(num value) { + final min = this.min ?? double.negativeInfinity; + final max = this.max ?? double.infinity; + return value >= min && value <= max; + } @override List get props => [min, max]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/document/validation/localized_document_validation_result.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/document/validation/localized_document_validation_result.dart index 94c06bb9ad6..e7dd83da5f8 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/document/validation/localized_document_validation_result.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/document/validation/localized_document_validation_result.dart @@ -1,6 +1,6 @@ import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; @@ -18,9 +18,12 @@ sealed class LocalizedDocumentValidationResult extends Equatable { const LocalizedSuccessfulDocumentValidation(), MissingRequiredDocumentValue() => const LocalizedMissingRequiredDocumentValue(), - DocumentNumOutOfRange() => const LocalizedDocumentNumOutOfRange(), - DocumentStringOutOfRange() => const LocalizedDocumentStringOutOfRange(), - DocumentItemsOutOfRange() => const LocalizedDocumentItemsOutOfRange(), + DocumentNumOutOfRange() => + LocalizedDocumentNumOutOfRange(range: result.expectedRange), + DocumentStringOutOfRange() => + LocalizedDocumentStringOutOfRange(range: result.expectedRange), + DocumentItemsOutOfRange() => + LocalizedDocumentItemsOutOfRange(range: result.expectedRange), }; } @@ -52,8 +55,7 @@ final class LocalizedMissingRequiredDocumentValue @override String? message(BuildContext context) { - // TODO(dtscalac): define the text - return 'LocalizedMissingRequiredDocumentValue'; + return context.l10n.errorValidationMissingRequiredField; } @override @@ -62,42 +64,81 @@ final class LocalizedMissingRequiredDocumentValue final class LocalizedDocumentNumOutOfRange extends LocalizedDocumentValidationResult { - const LocalizedDocumentNumOutOfRange(); + final Range range; + + const LocalizedDocumentNumOutOfRange({required this.range}); @override String? message(BuildContext context) { - // TODO(dtscalac): define the text - return 'LocalizedDocumentNumOutOfRange'; + final min = range.min; + final max = range.max; + + if (min != null && max != null) { + return context.l10n.errorValidationNumFieldOutOfRange(min, max); + } else if (min != null) { + return context.l10n.errorValidationNumFieldBelowMin(min); + } else if (max != null) { + return context.l10n.errorValidationNumFieldAboveMax(max); + } else { + // the range is unconstrained, so any value is allowed + return null; + } } @override - List get props => []; + List get props => [range]; } final class LocalizedDocumentStringOutOfRange extends LocalizedDocumentValidationResult { - const LocalizedDocumentStringOutOfRange(); + final Range range; + + const LocalizedDocumentStringOutOfRange({required this.range}); @override String? message(BuildContext context) { - // TODO(dtscalac): define the text - return 'LocalizedDocumentStringOutOfRange'; + final min = range.min; + final max = range.max; + + if (min != null && max != null) { + return context.l10n.errorValidationStringLengthOutOfRange(min, max); + } else if (min != null) { + return context.l10n.errorValidationStringLengthBelowMin(min); + } else if (max != null) { + return context.l10n.errorValidationStringLengthAboveMax(max); + } else { + // the range is unconstrained, so any value is allowed + return null; + } } @override - List get props => []; + List get props => [range]; } final class LocalizedDocumentItemsOutOfRange extends LocalizedDocumentValidationResult { - const LocalizedDocumentItemsOutOfRange(); + final Range range; + + const LocalizedDocumentItemsOutOfRange({required this.range}); @override String? message(BuildContext context) { - // TODO(dtscalac): define the text - return 'LocalizedDocumentItemsOutOfRange'; + final min = range.min; + final max = range.max; + + if (min != null && max != null) { + return context.l10n.errorValidationListItemsOutOfRange(min, max); + } else if (min != null) { + return context.l10n.errorValidationListItemsBelowMin(min); + } else if (max != null) { + return context.l10n.errorValidationListItemsAboveMax(max); + } else { + // the range is unconstrained, so any value is allowed + return null; + } } @override - List get props => []; + List get props => [range]; }