Skip to content

Commit

Permalink
feat(cat-voices): Add link(s) component (#1492)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
LynxLynxx authored Jan 13, 2025
1 parent c69e5c7 commit 5810331
Show file tree
Hide file tree
Showing 10 changed files with 423 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> property;
final bool isEditMode;
final ValueChanged<DocumentChange> onChanged;

const SingleLineHttpsUrlWidget({
super.key,
required this.property,
required this.isEditMode,
required this.onChanged,
});

@override
State<SingleLineHttpsUrlWidget> createState() =>
_SingleLineHttpsUrlWidgetState();
}

class _SingleLineHttpsUrlWidgetState extends State<SingleLineHttpsUrlWidget> {
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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String?>? 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<VoicesHttpsTextField> createState() => _VoicesHttpsTextFieldState();
}

class _VoicesHttpsTextFieldState extends State<VoicesHttpsTextField>
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<void> _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),
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@ class _VoicesTextFieldState extends State<VoicesTextField> {
return textStyle;
}),

suffixIcon: _wrapIconIfExists(
widget.decoration?.suffixIcon ?? _getStatusSuffixWidget(),
suffixIcon: _wrapSuffixIfExists(
widget.decoration?.suffixIcon,
const EdgeInsetsDirectional.only(start: 4, end: 8),
),
suffixText: widget.decoration?.suffixText,
Expand Down Expand Up @@ -425,6 +425,36 @@ class _VoicesTextFieldState extends State<VoicesTextField> {
);
}

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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -206,7 +207,6 @@ class _PropertyBuilder extends StatelessWidget {
'by $DocumentBuilderSectionTile',
);
case SingleLineTextEntryDefinition():
case SingleLineHttpsURLEntryDefinition():
case MultiLineTextEntryDefinition():
case MultiLineTextEntryMarkdownDefinition():
case MultiSelectDefinition():
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ final class SingleLineHttpsURLEntryDefinition
DocumentSchemaProperty<String> 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
Expand Down
Loading

0 comments on commit 5810331

Please sign in to comment.