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 new file mode 100644 index 00000000000..680c5aaa441 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart @@ -0,0 +1,107 @@ +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_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class DocumentTokenValueWidget extends StatefulWidget { + final DocumentNodeId id; + final String label; + final int? value; + 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.currency, + this.range, + this.isEditMode = false, + this.isRequired = true, + required this.onChanged, + }); + + @override + State createState() { + return _DocumentTokenValueWidgetState(); + } +} + +class _DocumentTokenValueWidgetState extends State { + late final VoicesIntFieldController _controller; + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + + _controller = VoicesIntFieldController(widget.value); + _controller.addListener(_handleControllerChange); + _focusNode = FocusNode(canRequestFocus: widget.isEditMode); + } + + @override + void didUpdateWidget(covariant DocumentTokenValueWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.value != oldWidget.value) { + _controller.value = widget.value; + } + + if (widget.isEditMode != oldWidget.isEditMode) { + _handleEditModeChanged(); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var label = widget.label; + if (widget.isRequired) { + label = '*$label'; + } + + return TokenField( + controller: _controller, + focusNode: _focusNode, + onFieldSubmitted: _notifyChangeListener, + labelText: label, + range: widget.range, + currency: widget.currency, + showHelper: widget.isEditMode, + readOnly: !widget.isEditMode, + ignorePointers: !widget.isEditMode, + ); + } + + void _handleControllerChange() { + final value = _controller.value; + _notifyChangeListener(value); + } + + void _handleEditModeChanged() { + _focusNode.canRequestFocus = widget.isEditMode; + + if (widget.isEditMode) { + _focusNode.requestFocus(); + } else { + _focusNode.unfocus(); + } + } + + void _notifyChangeListener(int? value) { + final change = DocumentChange(nodeId: widget.id, value: value); + widget.onChanged(change); + } +} 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 new file mode 100644 index 00000000000..cf6ec0c14d9 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/token_field.dart @@ -0,0 +1,118 @@ +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'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class TokenField extends StatelessWidget { + final VoicesIntFieldController? controller; + final ValueChanged? onFieldSubmitted; + final ValueChanged? onStatusChanged; + final String? labelText; + final String? errorText; + final FocusNode? focusNode; + final Range? range; + final Currency currency; + final bool showHelper; + final bool readOnly; + final bool? ignorePointers; + + const TokenField({ + super.key, + this.controller, + required this.onFieldSubmitted, + this.onStatusChanged, + this.labelText, + this.errorText, + this.focusNode, + this.range, + this.currency = const Currency.ada(), + this.showHelper = true, + this.readOnly = false, + this.ignorePointers, + }) : assert( + currency == const Currency.ada(), + 'Only supports ADA at the moment', + ); + + @override + Widget build(BuildContext context) { + final range = this.range; + + return VoicesIntField( + controller: controller, + focusNode: focusNode, + decoration: VoicesTextFieldDecoration( + labelText: labelText, + errorText: errorText, + prefixText: currency.symbol, + hintText: range != null ? '${range.min}' : null, + filled: true, + helper: range != null && showHelper + ? _Helper( + symbol: currency.symbol, + range: range, + ) + : null, + ), + validator: (int? value, text) => _validate(context, value, text), + onStatusChanged: onStatusChanged, + onFieldSubmitted: onFieldSubmitted, + readOnly: readOnly, + ignorePointers: ignorePointers, + ); + } + + VoicesTextFieldValidationResult _validate( + BuildContext context, + int? value, + String text, + ) { + // Value could not be parsed into int. + if (value == null && text.isNotEmpty) { + final message = context.l10n.errorValidationTokenNotParsed; + return VoicesTextFieldValidationResult.error(message); + } + + if (value != null && !(range?.contains(value) ?? true)) { + // Do not append any text + return const VoicesTextFieldValidationResult.error(); + } + + return const VoicesTextFieldValidationResult.none(); + } +} + +class _Helper extends StatelessWidget { + final String symbol; + final Range range; + + const _Helper({ + required this.symbol, + required this.range, + }); + + @override + Widget build(BuildContext context) { + // TODO(damian-molinski): Refactor text formatting with smarter syntax + return Text.rich( + TextSpan( + children: [ + TextSpan(text: context.l10n.requestedAmountShouldBeBetween), + const TextSpan(text: ' '), + TextSpan( + text: '$symbol${range.min}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: ' '), + TextSpan(text: context.l10n.and), + const TextSpan(text: ' '), + TextSpan( + text: '$symbol${range.max}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_int_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_int_field.dart new file mode 100644 index 00000000000..547912a3bc7 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_int_field.dart @@ -0,0 +1,35 @@ +import 'package:catalyst_voices/widgets/text_field/voices_num_field.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class VoicesIntFieldController extends VoicesNumFieldController { + VoicesIntFieldController([super.value]); +} + +class VoicesIntField extends VoicesNumField { + VoicesIntField({ + super.key, + VoicesIntFieldController? super.controller, + super.focusNode, + super.decoration, + super.onChanged, + super.validator, + super.onStatusChanged, + required super.onFieldSubmitted, + List? inputFormatters, + super.enabled, + super.readOnly, + super.ignorePointers, + }) : super( + codec: const IntCodec(), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + // Note. int.parse returns incorrect values for bigger Strings. + // If more is required use BigInt + if (kIsWeb) LengthLimitingTextInputFormatter(16), + ...?inputFormatters, + ], + ); +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_num_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_num_field.dart new file mode 100644 index 00000000000..b0318819bc2 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_num_field.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; + +import 'package:catalyst_voices/widgets/text_field/voices_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class VoicesNumFieldController extends ValueNotifier { + VoicesNumFieldController([super.value]); +} + +typedef VoicesNumFieldValidator = VoicesTextFieldValidationResult + Function(T? value, String text); + +class VoicesNumField extends StatefulWidget { + final Codec codec; + final VoicesNumFieldController? controller; + final WidgetStatesController? statesController; + final FocusNode? focusNode; + final int? maxLength; + final ValueChanged? onFieldSubmitted; + final VoicesTextFieldDecoration? decoration; + final TextInputType? keyboardType; + final List? inputFormatters; + final ValueChanged? onChanged; + final VoicesNumFieldValidator? validator; + final ValueChanged? onStatusChanged; + final bool enabled; + final bool readOnly; + final bool? ignorePointers; + + const VoicesNumField({ + super.key, + required this.codec, + this.controller, + this.statesController, + this.focusNode, + this.maxLength, + required this.onFieldSubmitted, + this.decoration, + this.keyboardType, + this.inputFormatters, + this.onChanged, + this.validator, + this.onStatusChanged, + this.enabled = true, + this.readOnly = false, + this.ignorePointers, + }); + + @override + State> createState() => _VoicesNumFieldState(); +} + +class _VoicesNumFieldState extends State> { + late final TextEditingController _textEditingController; + + VoicesNumFieldController? _controller; + + VoicesNumFieldController get _effectiveController { + return widget.controller ?? (_controller ??= VoicesNumFieldController()); + } + + @override + void initState() { + super.initState(); + + final num = _effectiveController.value; + final text = _toText(num); + + _textEditingController = TextEditingController(text: text); + _textEditingController.addListener(_handleTextChange); + + _effectiveController.addListener(_handleNumChange); + } + + @override + void didUpdateWidget(covariant VoicesNumField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.controller != oldWidget.controller) { + (oldWidget.controller ?? _controller)?.removeListener(_handleNumChange); + (widget.controller ?? _controller)?.addListener(_handleNumChange); + + if (widget.controller == null && oldWidget.controller != null) { + _controller = VoicesNumFieldController(oldWidget.controller?.value); + } else if (widget.controller != null && oldWidget.controller == null) { + _controller?.dispose(); + _controller = null; + } + + _handleNumChange(); + } + } + + @override + void dispose() { + _textEditingController.dispose(); + + _controller?.dispose(); + _controller = null; + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final onChanged = widget.onChanged; + final validator = widget.validator; + final onFieldSubmitted = widget.onFieldSubmitted; + + return VoicesTextField( + controller: _textEditingController, + statesController: widget.statesController, + focusNode: widget.focusNode, + maxLines: 1, + maxLength: widget.maxLength, + decoration: widget.decoration, + keyboardType: widget.keyboardType, + inputFormatters: [ + ...?widget.inputFormatters, + ], + onChanged: onChanged != null ? (value) => onChanged(_toNum(value)) : null, + validator: + validator != null ? (value) => validator(_toNum(value), value) : null, + onStatusChanged: widget.onStatusChanged, + onFieldSubmitted: onFieldSubmitted != null + ? (value) => onFieldSubmitted(_toNum(value)) + : null, + enabled: widget.enabled, + readOnly: widget.readOnly, + ignorePointers: widget.ignorePointers, + ); + } + + void _handleNumChange() { + final num = _effectiveController.value; + final text = _toText(num) ?? _textEditingController.text; + + if (_textEditingController.text != text) { + _textEditingController.text = text; + } + } + + void _handleTextChange() { + final text = _textEditingController.text; + final num = _toNum(text); + + if (_effectiveController.value != num) { + _effectiveController.value = num; + } + } + + String? _toText(T? num) { + try { + return num != null ? widget.codec.encode(num) : ''; + } on FormatException { + return null; + } + } + + T? _toNum(String text) { + try { + return widget.codec.decode(text); + } on FormatException { + return null; + } + } +} 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 2c9e363c8ae..cd037a9720f 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 @@ -14,6 +14,9 @@ class VoicesTextField extends StatefulWidget { /// [TextField.controller] final TextEditingController? controller; + /// [TextField.statesController] + final WidgetStatesController? statesController; + /// [TextField.focusNode] final FocusNode? focusNode; @@ -50,6 +53,12 @@ class VoicesTextField extends StatefulWidget { /// [TextField.enabled]. final bool enabled; + /// [TextField.readOnly]. + final bool readOnly; + + /// [TextField.ignorePointers]. + final bool? ignorePointers; + /// Whether the text field can be resized by the user /// in HTML's text area fashion. /// @@ -74,9 +83,12 @@ class VoicesTextField extends StatefulWidget { /// [AutovalidateMode] final AutovalidateMode? autovalidateMode; + final ValueChanged? onStatusChanged; + const VoicesTextField({ super.key, this.controller, + this.statesController, this.focusNode, this.decoration, this.autofocus = false, @@ -89,6 +101,8 @@ class VoicesTextField extends StatefulWidget { this.maxLines = 1, this.minLines, this.enabled = true, + this.readOnly = false, + this.ignorePointers, this.validator, this.onChanged, this.resizable, @@ -99,6 +113,7 @@ class VoicesTextField extends StatefulWidget { this.onSaved, this.inputFormatters, this.autovalidateMode, + this.onStatusChanged, }); @override @@ -109,7 +124,19 @@ class _VoicesTextFieldState extends State { TextEditingController? _customController; VoicesTextFieldValidationResult _validation = - const VoicesTextFieldValidationResult(status: VoicesTextFieldStatus.none); + const VoicesTextFieldValidationResult.none(); + + bool get _isResizable { + final resizable = widget.resizable ?? + (CatalystPlatform.isWebDesktop || CatalystPlatform.isDesktop); + + // expands property is not supported if any of these are specified, + // both must be null + final hasNoLineConstraints = + widget.maxLines == null && widget.minLines == null; + + return resizable && hasNoLineConstraints; + } @override void initState() { @@ -187,101 +214,13 @@ class _VoicesTextFieldState extends State { autofocus: widget.autofocus, expands: resizable, controller: _obtainController(), + statesController: widget.statesController, focusNode: widget.focusNode, onFieldSubmitted: widget.onFieldSubmitted, onSaved: widget.onSaved, inputFormatters: widget.inputFormatters, autovalidateMode: widget.autovalidateMode, - decoration: InputDecoration( - filled: widget.decoration?.filled, - fillColor: widget.decoration?.fillColor, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - border: widget.decoration?.border ?? - _getBorder( - orDefault: OutlineInputBorder( - borderSide: BorderSide( - color: theme.colorScheme.outlineVariant, - ), - ), - ), - enabledBorder: widget.decoration?.enabledBorder ?? - _getBorder( - orDefault: OutlineInputBorder( - borderSide: BorderSide( - color: theme.colorScheme.outlineVariant, - ), - ), - ), - disabledBorder: widget.decoration?.disabledBorder ?? - OutlineInputBorder( - borderSide: BorderSide( - color: theme.colorScheme.outline, - ), - ), - errorBorder: widget.decoration?.errorBorder ?? - OutlineInputBorder( - borderSide: BorderSide( - width: 2, - color: _getStatusColor( - orDefault: theme.colorScheme.error, - ), - ), - ), - focusedBorder: widget.decoration?.focusedBorder ?? - _getBorder( - orDefault: OutlineInputBorder( - borderSide: BorderSide( - width: 2, - color: theme.colorScheme.primary, - ), - ), - ), - focusedErrorBorder: widget.decoration?.focusedErrorBorder ?? - _getBorder( - orDefault: OutlineInputBorder( - borderSide: BorderSide( - width: 2, - color: theme.colorScheme.error, - ), - ), - ), - helperText: widget.decoration?.helperText, - helperStyle: widget.enabled - ? textTheme.bodySmall - : textTheme.bodySmall! - .copyWith(color: theme.colors.textDisabled), - hintText: widget.decoration?.hintText, - hintStyle: _getHintStyle( - textTheme, - theme, - orDefault: widget.enabled - ? textTheme.bodyLarge - : textTheme.bodyLarge! - .copyWith(color: theme.colors.textDisabled), - ), - errorText: - widget.decoration?.errorText ?? _validation.errorMessage, - errorMaxLines: widget.decoration?.errorMaxLines, - errorStyle: _getErrorStyle(textTheme, theme), - prefixIcon: _wrapIconIfExists( - widget.decoration?.prefixIcon, - const EdgeInsetsDirectional.only(start: 8, end: 4), - ), - prefixText: widget.decoration?.prefixText, - suffixIcon: _wrapIconIfExists( - widget.decoration?.suffixIcon ?? _getStatusSuffixWidget(), - const EdgeInsetsDirectional.only(start: 4, end: 8), - ), - suffixText: widget.decoration?.suffixText, - counterText: widget.decoration?.counterText, - counterStyle: widget.enabled - ? textTheme.bodySmall - : textTheme.bodySmall! - .copyWith(color: theme.colors.textDisabled), - ), + decoration: _buildDecoration(context), keyboardType: widget.keyboardType, textInputAction: widget.textInputAction, textCapitalization: widget.textCapitalization, @@ -290,6 +229,8 @@ class _VoicesTextFieldState extends State { maxLines: widget.maxLines, minLines: widget.minLines, maxLength: widget.maxLength, + readOnly: widget.readOnly, + ignorePointers: widget.ignorePointers, enabled: widget.enabled, onChanged: widget.onChanged, ), @@ -298,16 +239,120 @@ class _VoicesTextFieldState extends State { ); } - bool get _isResizable { - final resizable = widget.resizable ?? - (CatalystPlatform.isWebDesktop || CatalystPlatform.isDesktop); + InputDecoration _buildDecoration(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + final colorScheme = theme.colorScheme; + + return InputDecoration( + filled: widget.decoration?.filled, + fillColor: widget.decoration?.fillColor, + // Note. prefixText is not visible when field is not focused without + // this. + // Should be removed once this is resolved + // https://github.com/flutter/flutter/issues/64552#issuecomment-2074034179 + floatingLabelBehavior: FloatingLabelBehavior.always, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: widget.decoration?.border ?? + _getBorder( + orDefault: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + ), + enabledBorder: widget.decoration?.enabledBorder ?? + _getBorder( + orDefault: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + ), + disabledBorder: widget.decoration?.disabledBorder ?? + OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outline, + ), + ), + errorBorder: widget.decoration?.errorBorder ?? + OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: _getStatusColor( + orDefault: colorScheme.error, + ), + ), + ), + focusedBorder: widget.decoration?.focusedBorder ?? + _getBorder( + orDefault: OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: colorScheme.primary, + ), + ), + ), + focusedErrorBorder: widget.decoration?.focusedErrorBorder ?? + _getBorder( + orDefault: OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: colorScheme.error, + ), + ), + ), + helper: widget.decoration?.helper != null + ? DefaultTextStyle( + style: widget.enabled + ? textTheme.bodySmall! + : textTheme.bodySmall!.copyWith(color: colors.textDisabled), + child: widget.decoration!.helper!, + ) + : null, + helperText: widget.decoration?.helperText, + helperStyle: widget.enabled + ? textTheme.bodySmall + : textTheme.bodySmall!.copyWith(color: colors.textDisabled), + hintText: widget.decoration?.hintText, + hintStyle: _getHintStyle( + textTheme, + theme, + orDefault: textTheme.bodyLarge!.copyWith(color: colors.textDisabled), + ), + errorText: widget.decoration?.errorText ?? _validation.errorMessage, + errorMaxLines: widget.decoration?.errorMaxLines, + errorStyle: _getErrorStyle(textTheme, theme), + prefixIcon: _wrapIconIfExists( + widget.decoration?.prefixIcon, + const EdgeInsetsDirectional.only(start: 8, end: 4), + ), + prefixText: widget.decoration?.prefixText, + prefixStyle: WidgetStateTextStyle.resolveWith((states) { + var textStyle = textTheme.bodyLarge ?? const TextStyle(); - // expands property is not supported if any of these are specified, - // both must be null - final hasNoLineConstraints = - widget.maxLines == null && widget.minLines == null; + if (!states.contains(WidgetState.focused) && + _obtainController().text.isEmpty) { + textStyle = textStyle.copyWith(color: colors.textDisabled); + } - return resizable && hasNoLineConstraints; + return textStyle; + }), + + suffixIcon: _wrapIconIfExists( + widget.decoration?.suffixIcon ?? _getStatusSuffixWidget(), + const EdgeInsetsDirectional.only(start: 4, end: 8), + ), + suffixText: widget.decoration?.suffixText, + counterText: widget.decoration?.counterText, + counterStyle: widget.enabled + ? textTheme.bodySmall + : textTheme.bodySmall!.copyWith(color: colors.textDisabled), + ); } InputBorder _getBorder({required InputBorder orDefault}) { @@ -425,20 +470,14 @@ class _VoicesTextFieldState extends State { final errorText = widget.decoration?.errorText; if (errorText != null) { _onValidationResultChanged( - VoicesTextFieldValidationResult( - status: VoicesTextFieldStatus.error, - errorMessage: errorText, - ), + VoicesTextFieldValidationResult.error(errorText), ); return; } final result = widget.validator?.call(value); _onValidationResultChanged( - result ?? - const VoicesTextFieldValidationResult( - status: VoicesTextFieldStatus.none, - ), + result ?? const VoicesTextFieldValidationResult.none(), ); } @@ -446,6 +485,7 @@ class _VoicesTextFieldState extends State { if (_validation != validation) { setState(() { _validation = validation; + widget.onStatusChanged?.call(validation.status); }); } } @@ -482,6 +522,11 @@ class VoicesTextFieldValidationResult with EquatableMixin { 'errorMessage can be only used for warning or error status', ); + const VoicesTextFieldValidationResult.none() + : this( + status: VoicesTextFieldStatus.none, + ); + /// Returns a successful validation result. /// /// The method was designed to be used as @@ -585,6 +630,9 @@ class VoicesTextFieldDecoration { /// the floating behavior and is instead above the text field instead. final String? labelText; + /// [InputDecoration.helper]. + final Widget? helper; + /// [InputDecoration.helperText]. final String? helperText; @@ -637,6 +685,7 @@ class VoicesTextFieldDecoration { this.focusedBorder, this.focusedErrorBorder, this.labelText, + this.helper, this.helperText, this.hintText, this.hintStyle, 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 bb1b25db908..ff5209af15b 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 @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/document_builder/document_token_value_widget.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'; @@ -45,6 +46,10 @@ class _DocumentBuilderSectionTileState _editedSection = widget.section; _builder = _editedSection.toBuilder(); + + // TODO(damian-molinski): validation + _isValid = + _editedSection.properties.every((element) => element.value != null); } @override @@ -55,6 +60,10 @@ class _DocumentBuilderSectionTileState _editedSection = widget.section; _builder = _editedSection.toBuilder(); _pendingChanges.clear(); + + // TODO(damian-molinski): validation + _isValid = + _editedSection.properties.every((element) => element.value != null); } } @@ -80,7 +89,7 @@ class _DocumentBuilderSectionTileState key: ObjectKey(property.schema.nodeId), property: property, isEditMode: _isEditMode, - onPropertyChanged: _handlePropertyChange, + onChanged: _handlePropertyChange, ), ], if (_isEditMode) ...[ @@ -102,12 +111,14 @@ class _DocumentBuilderSectionTileState // ignore: unnecessary_lambdas setState(() { _pendingChanges.clear(); + _isEditMode = false; }); } void _toggleEditMode() { setState(() { _isEditMode = !_isEditMode; + _pendingChanges.clear(); }); } @@ -118,7 +129,8 @@ class _DocumentBuilderSectionTileState _pendingChanges.add(change); // TODO(damian-molinski): validation - _isValid = false; + _isValid = + _editedSection.properties.every((element) => element.value != null); }); } } @@ -154,7 +166,6 @@ class _Header extends StatelessWidget { style: Theme.of(context).textTheme.labelSmall, ), ), - const SizedBox(width: 16), ], ); } @@ -184,13 +195,13 @@ class _Footer extends StatelessWidget { class _PropertyBuilder extends StatelessWidget { final DocumentProperty property; final bool isEditMode; - final ValueChanged onPropertyChanged; + final ValueChanged onChanged; const _PropertyBuilder({ required super.key, required this.property, required this.isEditMode, - required this.onPropertyChanged, + required this.onChanged, }); @override @@ -217,6 +228,16 @@ class _PropertyBuilder extends StatelessWidget { case TagGroupDefinition(): case TagSelectionDefinition(): case TokenValueCardanoADADefinition(): + return DocumentTokenValueWidget( + id: property.schema.nodeId, + label: property.schema.title ?? '', + value: property.value is int ? property.value! as int : null, + currency: const Currency.ada(), + range: property.schema.range, + isEditMode: isEditMode, + isRequired: property.schema.isRequired, + onChanged: onChanged, + ); case DurationInMonthsDefinition(): case YesNoChoiceDefinition(): case AgreementConfirmationDefinition(): diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index 9581150f34e..06873115bc5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -68,9 +68,11 @@ export 'separators/voices_divider.dart'; export 'separators/voices_text_divider.dart'; export 'separators/voices_vertical_divider.dart'; export 'text_field/seed_phrase_field.dart'; +export 'text_field/token_field.dart'; export 'text_field/voices_autocomplete.dart'; export 'text_field/voices_date_time_field.dart'; export 'text_field/voices_email_text_field.dart'; +export 'text_field/voices_int_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; export 'tiles/document_builder_section_tile.dart'; 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 8d8ee0acd67..c4998972200 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 @@ -1178,5 +1178,11 @@ } }, "searchProposals": "Search Proposals", - "search": "Search…" + "search": "Search…", + "requestedAmountShouldBeBetween": "Requested amount should be between", + "and": "and", + "@and": { + "description": "General text. May be used in context of A4000 and $5000" + }, + "errorValidationTokenNotParsed": "Invalid input. Could not parse parse." } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart index b89ce33d145..8ec48596546 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -16,6 +16,7 @@ export 'document/document_schema.dart'; export 'errors/errors.dart'; export 'file/voices_file.dart'; export 'markdown_data.dart'; +export 'money/money.dart'; export 'optional.dart'; export 'proposal/guidance.dart'; export 'proposal/proposal.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/currency.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/currency.dart new file mode 100644 index 00000000000..c50acef4909 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/currency.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +// TODO(damian-molinski): Convert later using money2 package. +final class Currency extends Equatable { + final String name; + final String symbol; + + const Currency({ + required this.name, + required this.symbol, + }); + + const Currency.ada() + : this( + name: 'ADA', + symbol: '₳', + ); + + @override + List get props => [ + name, + symbol, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money.dart new file mode 100644 index 00000000000..866f51f3149 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/money/money.dart @@ -0,0 +1 @@ +export 'currency.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart index 901b86bc77b..3618ac4824b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart @@ -1,6 +1,7 @@ export 'cache/cache.dart'; export 'cache/local_tll_cache.dart'; export 'cache/ttl_cache.dart'; +export 'codecs/codecs.dart'; export 'common/build_config.dart'; export 'common/build_environment.dart'; export 'crypto/crypto_service.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/codecs/codecs.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/codecs/codecs.dart new file mode 100644 index 00000000000..7a31f2e6c31 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/codecs/codecs.dart @@ -0,0 +1 @@ +export 'int_codec.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/codecs/int_codec.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/codecs/int_codec.dart new file mode 100644 index 00000000000..1bbcff777ac --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/codecs/int_codec.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +class IntCodec extends Codec { + const IntCodec(); + + @override + Converter get decoder => const IntDecoder(); + + @override + Converter get encoder => const IntEncoder(); +} + +class IntDecoder extends Converter { + const IntDecoder(); + + @override + int convert(String input) => int.parse(input); +} + +class IntEncoder extends Converter { + const IntEncoder(); + + @override + String convert(int input) => '$input'; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart index b6152b126b9..d67171ab5fc 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/formatter/cryptocurrency_formatter.dart @@ -1,10 +1,10 @@ import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:intl/intl.dart'; +// TODO(damian-molinski): Convert later using money2 package. /// Formats amounts of ADA cryptocurrency. abstract class CryptocurrencyFormatter { - static const adaSymbol = '₳'; - static const _million = 1000000; static const _thousand = 1000; @@ -17,15 +17,16 @@ abstract class CryptocurrencyFormatter { /// - ₳1000000 = ₳1M static String formatAmount(Coin amount) { final numberFormat = NumberFormat('#.##'); + final symbol = const Currency.ada().symbol; final ada = amount.ada; if (ada >= _million) { final millions = ada / _million; - return '$adaSymbol${numberFormat.format(millions)}M'; + return '$symbol${numberFormat.format(millions)}M'; } else if (ada >= _thousand) { final thousands = ada / _thousand; - return '$adaSymbol${numberFormat.format(thousands)}K'; + return '$symbol${numberFormat.format(thousands)}K'; } else { - return adaSymbol + numberFormat.format(ada); + return symbol + numberFormat.format(ada); } } @@ -38,6 +39,7 @@ abstract class CryptocurrencyFormatter { /// - ₳0.123 = 0.123₳ static String formatExactAmount(Coin amount) { final numberFormat = NumberFormat('#.######'); - return numberFormat.format(amount.ada) + adaSymbol; + final symbol = const Currency.ada().symbol; + return numberFormat.format(amount.ada) + symbol; } } 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 ad17adab392..029e75c1197 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 @@ -13,6 +13,8 @@ class Range extends Equatable { return Range(min: min, max: max); } + bool contains(T value) => value >= min && value <= max; + @override List get props => [min, max]; }