From 581033186f360510b2e00670190b9487789863a1 Mon Sep 17 00:00:00 2001 From: Ryszard Schossler <51096731+LynxLynxx@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:01:56 +0100 Subject: [PATCH] feat(cat-voices): Add link(s) component (#1492) * feat: creating ui layout * feat: adding validation for text field * feat: adding localization string for validation * chore: adding ui styling * feat: changing logic of suffix icon in voices_textfield * fix: remove unecessary check * fix: relisten to controller if widget changes * chore: conditional logic, making variables private * chore: changing widget from stateful to stateless * chore: adding extension to format description --- .../lib/common/ext/document_property_ext.dart | 8 + .../single_line_https_url_widget.dart.dart | 123 +++++++++++++++ .../text_field/voices_https_text_field.dart | 147 ++++++++++++++++++ .../widgets/text_field/voices_text_field.dart | 34 +++- .../tiles/document_builder_section_tile.dart | 11 +- .../lib/l10n/intl_en.arb | 9 +- ...ingle_line_https_url_entry_definition.dart | 7 +- .../lib/src/document/document_validator.dart | 26 ++++ .../validation/document_validation_test.dart | 50 ++++++ .../localized_document_validation_result.dart | 14 ++ 10 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/common/ext/document_property_ext.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/document_builder/single_line_https_url_widget.dart.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/text_field/voices_https_text_field.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_models/test/document/validation/document_validation_test.dart diff --git a/catalyst_voices/apps/voices/lib/common/ext/document_property_ext.dart b/catalyst_voices/apps/voices/lib/common/ext/document_property_ext.dart new file mode 100644 index 00000000000..8034d8f499c --- /dev/null +++ b/catalyst_voices/apps/voices/lib/common/ext/document_property_ext.dart @@ -0,0 +1,8 @@ +import 'package:catalyst_voices/common/ext/string_ext.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; + +extension DocumentPropertyExt on DocumentProperty { + String get formattedDescription { + return (schema.description ?? '').starred(isEnabled: schema.isRequired); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/single_line_https_url_widget.dart.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/single_line_https_url_widget.dart.dart new file mode 100644 index 00000000000..9474d2f54bd --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/single_line_https_url_widget.dart.dart @@ -0,0 +1,123 @@ +import 'package:catalyst_voices/common/ext/document_property_ext.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_https_text_field.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/material.dart'; + +class SingleLineHttpsUrlWidget extends StatefulWidget { + final DocumentProperty property; + final bool isEditMode; + final ValueChanged onChanged; + + const SingleLineHttpsUrlWidget({ + super.key, + required this.property, + required this.isEditMode, + required this.onChanged, + }); + + @override + State createState() => + _SingleLineHttpsUrlWidgetState(); +} + +class _SingleLineHttpsUrlWidgetState extends State { + late final TextEditingController _textEditingController; + late final FocusNode _focusNode; + + String get _description => widget.property.formattedDescription; + + @override + void initState() { + super.initState(); + _textEditingController = TextEditingController(text: widget.property.value); + _textEditingController.addListener(_handleControllerChange); + _focusNode = FocusNode(canRequestFocus: widget.isEditMode); + } + + @override + void didUpdateWidget(covariant SingleLineHttpsUrlWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isEditMode != widget.isEditMode && + widget.isEditMode == false) { + _textEditingController.text = widget.property.value ?? ''; + } + + if (widget.isEditMode != oldWidget.isEditMode) { + _handleEditModeChanged(); + } + } + + @override + void dispose() { + _textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (_description.isNotEmpty) ...[ + Text( + _description, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + ], + VoicesHttpsTextField( + controller: _textEditingController, + focusNode: _focusNode, + onFieldSubmitted: _notifyChangeListener, + validator: _validate, + enabled: widget.isEditMode, + ), + ], + ); + } + + void _handleControllerChange() { + final controllerValue = _textEditingController.text; + if (widget.property.value != controllerValue && + controllerValue.isNotEmpty) { + _notifyChangeListener(controllerValue); + } + } + + void _notifyChangeListener(String? value) { + final change = DocumentChange( + nodeId: widget.property.schema.nodeId, + value: value, + ); + + widget.onChanged(change); + } + + VoicesTextFieldValidationResult _validate(String? value) { + if (value == null || value.isEmpty) { + return const VoicesTextFieldValidationResult.none(); + } + final schema = widget.property.schema; + final result = schema.validatePropertyValue(value); + if (result.isValid) { + return const VoicesTextFieldValidationResult.success(); + } else { + final localized = LocalizedDocumentValidationResult.from(result); + return VoicesTextFieldValidationResult.error(localized.message(context)); + } + } + + void _handleEditModeChanged() { + _focusNode.canRequestFocus = widget.isEditMode; + + if (widget.isEditMode) { + _focusNode.requestFocus(); + } else { + _focusNode.unfocus(); + } + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_https_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_https_text_field.dart new file mode 100644 index 00000000000..434e1b4661d --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_https_text_field.dart @@ -0,0 +1,147 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class VoicesHttpsTextField extends StatefulWidget { + final ValueChanged? onFieldSubmitted; + final TextEditingController? controller; + final FocusNode? focusNode; + final VoicesTextFieldValidator? validator; + final bool enabled; + + const VoicesHttpsTextField({ + super.key, + this.onFieldSubmitted, + this.focusNode, + this.enabled = false, + this.controller, + this.validator, + }); + + @override + State createState() => _VoicesHttpsTextFieldState(); +} + +class _VoicesHttpsTextFieldState extends State + with LaunchUrlMixin { + TextEditingController? _controller; + TextEditingController get _effectiveController { + return widget.controller ?? (_controller ??= TextEditingController()); + } + + @override + void dispose() { + _controller?.dispose(); + _controller = null; + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.enabled ? null : _launchUrl, + child: VoicesTextField( + controller: _effectiveController, + focusNode: widget.focusNode, + onFieldSubmitted: widget.onFieldSubmitted, + validator: widget.validator, + decoration: VoicesTextFieldDecoration( + hintText: + widget.enabled ? context.l10n.noUrlAdded : context.l10n.addUrl, + prefixIcon: VoicesAssets.icons.link.buildIcon(), + showStatusSuffixIcon: widget.enabled, + suffixIcon: ValueListenableBuilder( + valueListenable: _effectiveController, + builder: (context, value, child) { + return _AdditionalSuffixIcons( + enabled: widget.enabled, + canClear: value.text.isNotEmpty, + onLinkTap: _launchUrl, + onClearTap: _onClearTap, + ); + }, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + fillColor: Theme.of(context).colors.onSurfaceNeutralOpaqueLv1, + filled: true, + ), + style: _getTextStyle(context), + enabled: widget.enabled, + readOnly: !widget.enabled, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + ); + } + + TextStyle? _getTextStyle(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + if (widget.enabled) { + return textTheme.bodyLarge; + } + return textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ); + } + + Future _launchUrl() async { + if (_effectiveController.text.isEmpty) return; + + final url = Uri.tryParse(_effectiveController.text); + if (url != null) { + await launchHrefUrl(url); + } + } + + void _onClearTap() { + _effectiveController.clear(); + } +} + +class _AdditionalSuffixIcons extends StatelessWidget { + final bool enabled; + final bool canClear; + final VoidCallback onLinkTap; + final VoidCallback onClearTap; + + const _AdditionalSuffixIcons({ + required this.enabled, + required this.canClear, + required this.onLinkTap, + required this.onClearTap, + }); + + @override + Widget build(BuildContext context) { + if (!enabled) { + return VoicesIconButton( + onTap: onLinkTap, + child: VoicesAssets.icons.externalLink.buildIcon( + color: Theme.of(context).colorScheme.primary, + ), + ); + } + return Offstage( + offstage: !canClear, + child: TextButton( + onPressed: onClearTap, + child: Text(context.l10n.clear), + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index cd037a9720f..1af6a00b13b 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -343,8 +343,8 @@ class _VoicesTextFieldState extends State { return textStyle; }), - suffixIcon: _wrapIconIfExists( - widget.decoration?.suffixIcon ?? _getStatusSuffixWidget(), + suffixIcon: _wrapSuffixIfExists( + widget.decoration?.suffixIcon, const EdgeInsetsDirectional.only(start: 4, end: 8), ), suffixText: widget.decoration?.suffixText, @@ -425,6 +425,36 @@ class _VoicesTextFieldState extends State { ); } + Widget? _wrapSuffixIfExists(Widget? child, EdgeInsetsDirectional padding) { + final statusSuffixWidget = _getStatusSuffixWidget(); + if (child == null) return statusSuffixWidget; + + return Padding( + padding: padding, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData( + size: 24, + color: Theme.of(context).colors.iconsForeground, + ), + child: Align( + widthFactor: 1, + heightFactor: 1, + child: child, + ), + ), + if (statusSuffixWidget != null) ...[ + const SizedBox(width: 2), + statusSuffixWidget, + ], + ], + ), + ); + } + Color _getStatusColor({required Color orDefault}) { switch (_validation.status) { case VoicesTextFieldStatus.none: 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 9f7ff16d789..88f0465f37e 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 @@ -2,6 +2,7 @@ import 'package:catalyst_voices/widgets/document_builder/agreement_confirmation_ import 'package:catalyst_voices/widgets/document_builder/document_token_value_widget.dart'; import 'package:catalyst_voices/widgets/document_builder/single_dropdown_selection_widget.dart'; import 'package:catalyst_voices/widgets/document_builder/single_grouped_tag_selector_widget.dart'; +import 'package:catalyst_voices/widgets/document_builder/single_line_https_url_widget.dart.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'; @@ -206,7 +207,6 @@ class _PropertyBuilder extends StatelessWidget { 'by $DocumentBuilderSectionTile', ); case SingleLineTextEntryDefinition(): - case SingleLineHttpsURLEntryDefinition(): case MultiLineTextEntryDefinition(): case MultiLineTextEntryMarkdownDefinition(): case MultiSelectDefinition(): @@ -221,7 +221,14 @@ class _PropertyBuilder extends StatelessWidget { case YesNoChoiceDefinition(): case SPDXLicenceOrUrlDefinition(): case LanguageCodeDefinition(): - throw UnimplementedError(); + throw UnimplementedError('${definition.type} not implemented'); + case SingleLineHttpsURLEntryDefinition(): + final castProperty = definition.castProperty(property); + return SingleLineHttpsUrlWidget( + property: castProperty, + isEditMode: isEditMode, + onChanged: onChanged, + ); case SingleGroupedTagSelectorDefinition(): final castProperty = definition.castProperty(property); return SingleGroupedTagSelectorWidget( 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 61dd36220e5..f2638c42b23 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 @@ -1285,6 +1285,13 @@ "@noTagSelected": { "description": "For example in context of document builder" }, + "errorValidationPatternMismatch": "The value does not match the valid pattern.", + "@errorValidationPatternMismatch": { + "description": "Validation error when the user entered value does not match the pattern" + }, "singleGroupedTagSelectorTitle": "Please choose the most relevant category group and tag related to the outcomes of your proposal", - "singleGroupedTagSelectorRelevantTag": "Select the most relevant tag" + "singleGroupedTagSelectorRelevantTag": "Select the most relevant tag", + "noUrlAdded": "No URL added", + "addUrl": "Add URL", + "clear": "Clear" } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/definitions/single_line_https_url_entry_definition.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/definitions/single_line_https_url_entry_definition.dart index ea8d25e9a44..a66608299ae 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/definitions/single_line_https_url_entry_definition.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/definitions/single_line_https_url_entry_definition.dart @@ -17,7 +17,12 @@ final class SingleLineHttpsURLEntryDefinition DocumentSchemaProperty schema, String? value, ) { - return DocumentValidator.validateString(schema, value); + final stringValidationResult = + DocumentValidator.validateString(schema, value); + if (stringValidationResult.isInvalid) { + return stringValidationResult; + } + return DocumentValidator.validatePattern(pattern, value); } @override diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_validator.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_validator.dart index f2c9d15e612..ac141a8dafe 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_validator.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_validator.dart @@ -97,6 +97,19 @@ final class DocumentValidator { ) { return validateBasic(schema, value); } + + static DocumentValidationResult validatePattern( + String pattern, + String? value, + ) { + final regex = RegExp(pattern); + if (value != null) { + if (!regex.hasMatch(value)) { + return DocumentPatternMismatch(pattern: pattern, value: value); + } + } + return const SuccessfulDocumentValidation(); + } } sealed class DocumentValidationResult extends Equatable { @@ -176,3 +189,16 @@ final class DocumentItemsOutOfRange @override List get props => [invalidNodeId, expectedRange, actualItems]; } + +final class DocumentPatternMismatch extends DocumentValidationResult { + final String pattern; + final String? value; + + const DocumentPatternMismatch({ + required this.pattern, + required this.value, + }); + + @override + List get props => [pattern, value]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/document/validation/document_validation_test.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/document/validation/document_validation_test.dart new file mode 100644 index 00000000000..b18920102c5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/document/validation/document_validation_test.dart @@ -0,0 +1,50 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:test/test.dart'; + +void main() { + group('$DocumentValidator', () { + group('$SingleLineHttpsURLEntryDefinition validation', () { + late DocumentProperty property; + + setUp(() { + property = const DocumentProperty( + schema: DocumentSchemaProperty( + definition: SingleLineHttpsURLEntryDefinition( + type: DocumentDefinitionsObjectType.string, + note: '', + format: DocumentDefinitionsFormat.uri, + pattern: '^https://', + ), + nodeId: DocumentNodeId.root, + id: '', + title: '', + description: '', + defaultValue: null, + guidance: '', + enumValues: null, + numRange: null, + strLengthRange: null, + itemsRange: null, + oneOf: null, + isRequired: true, + ), + value: null, + validationResult: SuccessfulDocumentValidation(), + ); + }); + + test('value cannot be null when required', () { + final result = property.schema.validatePropertyValue(null); + + expect(result, isA()); + }); + + test('value is valid when matches pattern', () { + final result = + property.schema.validatePropertyValue('https://www.catalyst.org/'); + + expect(result, isA()); + }); + }); + }); +} 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 e7dd83da5f8..430d66961de 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 @@ -24,6 +24,7 @@ sealed class LocalizedDocumentValidationResult extends Equatable { LocalizedDocumentStringOutOfRange(range: result.expectedRange), DocumentItemsOutOfRange() => LocalizedDocumentItemsOutOfRange(range: result.expectedRange), + DocumentPatternMismatch() => const LocalizedDocumentPatternMismatch(), }; } @@ -142,3 +143,16 @@ final class LocalizedDocumentItemsOutOfRange @override List get props => [range]; } + +final class LocalizedDocumentPatternMismatch + extends LocalizedDocumentValidationResult { + const LocalizedDocumentPatternMismatch(); + + @override + String? message(BuildContext context) { + return context.l10n.errorValidationPatternMismatch; + } + + @override + List get props => []; +}