diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/multiline_text_entry_markdown_widget.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/multiline_text_entry_markdown_widget.dart new file mode 100644 index 00000000000..e8532173c66 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/multiline_text_entry_markdown_widget.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:catalyst_voices/common/codecs/markdown_codec.dart'; +import 'package:catalyst_voices/common/ext/document_property_ext.dart'; +import 'package:catalyst_voices/widgets/rich_text/voices_rich_text.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; + +class MultilineTextEntryMarkdownWidget extends StatefulWidget { + final DocumentProperty property; + final ValueChanged onChanged; + final bool isEditMode; + final bool isRequired; + + const MultilineTextEntryMarkdownWidget({ + super.key, + required this.property, + required this.onChanged, + required this.isEditMode, + required this.isRequired, + }); + + @override + State createState() => + _MultilineTextEntryMarkdownWidgetState(); +} + +class _MultilineTextEntryMarkdownWidgetState + extends State { + late VoicesRichTextController _controller; + late final VoicesRichTextFocusNode _focus; + late final ScrollController _scrollController; + + quill.Document? _observedDocument; + StreamSubscription? _documentChangeSub; + quill.Document? _preEditDocument; + + String get _description => widget.property.formattedDescription; + int? get _maxLength => widget.property.schema.strLengthRange?.max; + String? get _value => widget.property.value; + + @override + void initState() { + super.initState(); + + _controller = _buildController(value: _value); + _controller.addListener(_onControllerChanged); + + _focus = VoicesRichTextFocusNode(); + _scrollController = ScrollController(); + } + + @override + void didUpdateWidget(covariant MultilineTextEntryMarkdownWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isEditMode != oldWidget.isEditMode) { + _focus.toggleFocus(enabled: widget.isEditMode); + _controller.readOnly = !widget.isEditMode; + _toggleEditMode(); + } + + if (widget.property.value != oldWidget.property.value) { + _controller.dispose(); + _controller = _buildController(value: _value); + _controller.addListener(_onControllerChanged); + } + } + + @override + void dispose() { + _controller.dispose(); + _focus.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return VoicesRichText( + controller: _controller, + enabled: widget.isEditMode, + title: _description, + focusNode: _focus, + scrollController: _scrollController, + charsLimit: _maxLength, + validator: (val) { + return null; + + // TODO(LynxxLynx): implement validator when we got answer how to + // validate formatted document against maxLength + }, + ); + } + + VoicesRichTextController _buildController({ + String? value, + }) { + if (value != null) { + final input = MarkdownData(value); + final delta = markdown.encoder.convert(input); + return VoicesRichTextController( + document: quill.Document.fromDelta(delta), + selection: const TextSelection.collapsed(offset: 0), + ); + } else { + return VoicesRichTextController( + document: quill.Document(), + selection: const TextSelection.collapsed(offset: 0), + ); + } + } + + void _onControllerChanged() { + if (_observedDocument != _controller.document) { + _updateObservedDocument(); + } + } + + void _updateObservedDocument() { + _observedDocument = _controller.document; + unawaited(_documentChangeSub?.cancel()); + _documentChangeSub = _observedDocument?.changes.listen(_onDocumentChanged); + } + + void _onDocumentChanged(quill.DocChange change) { + if (change.change.last.data != '\n') { + _notifyChangeListener(); + } + } + + void _notifyChangeListener() { + final delta = _controller.document.toDelta(); + final markdownData = markdown.decoder.convert(delta); + widget.onChanged( + DocumentChange( + nodeId: widget.property.schema.nodeId, + value: markdownData.data, + ), + ); + } + + void _toggleEditMode() { + _controller.readOnly = !widget.isEditMode; + if (widget.isEditMode) { + _startEdit(); + } else { + _stopEdit(); + } + } + + void _startEdit() { + final currentDocument = _controller.document; + _preEditDocument = quill.Document.fromDelta(currentDocument.toDelta()); + } + + void _stopEdit() { + final preEditDocument = _preEditDocument; + _preEditDocument = null; + + if (preEditDocument != null) { + _controller.document = preEditDocument; + } + } +} + +/// This focus helps to interact with [VoicesRichText] widget +/// When widget is not in edit mode this focus allows user to interact with +/// links and other elements that are inside of the textfield.. +class VoicesRichTextFocusNode extends FocusNode { + bool _disableFocus = true; + + // Can't request focus when disabled + @override + bool get canRequestFocus => !_disableFocus; + + @override + bool get hasFocus => !_disableFocus && (hasPrimaryFocus); + + void allowFocus() => _disableFocus = false; + void disableFocus() => _disableFocus = true; + + void toggleFocus({required bool enabled}) { + enabled ? allowFocus() : disableFocus(); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart index a8b4aeab9cb..673667abab6 100644 --- a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart +++ b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart @@ -1,7 +1,3 @@ -import 'dart:async'; - -import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart'; -import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; import 'package:catalyst_voices/widgets/rich_text/voices_rich_text_limit.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; @@ -11,10 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; -typedef CanEditDocumentGetter = bool Function(Document document); - -bool _alwaysAllowEdit(Document document) => true; - final class VoicesRichTextController extends QuillController { VoicesRichTextController({ required super.document, @@ -22,302 +14,129 @@ final class VoicesRichTextController extends QuillController { }); } -final class VoicesRichTextEditModeController extends ValueNotifier { - //ignore: avoid_positional_boolean_parameters - VoicesRichTextEditModeController([super.value = false]); -} - -/// A component for rich text writing -/// using Quill under the hood -/// https://pub.dev/packages/flutter_quill -class VoicesRichText extends StatefulWidget { +class VoicesRichText extends FormField { + final VoicesRichTextController controller; final String title; - final VoicesRichTextController? controller; - final VoicesRichTextEditModeController? editModeController; - final FocusNode? focusNode; + final FocusNode focusNode; + final ScrollController scrollController; final int? charsLimit; - final CanEditDocumentGetter canEditDocumentGetter; - final VoidCallback? onEditBlocked; - final ValueChanged? onSaved; - const VoicesRichText({ + VoicesRichText({ super.key, - this.title = '', - this.controller, - this.editModeController, - this.focusNode, + super.enabled, + super.autovalidateMode = AutovalidateMode.always, + required this.controller, + required this.title, + required this.focusNode, + required this.scrollController, this.charsLimit, - this.canEditDocumentGetter = _alwaysAllowEdit, - this.onEditBlocked, - this.onSaved, - }); - - @override - State createState() => _VoicesRichTextState(); + super.validator, + }) : super( + builder: (field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Offstage( + offstage: !enabled, + child: _Toolbar( + controller: controller, + ), + ), + _Title(title: title), + _EditorDecoration( + isEditMode: enabled, + isInvalid: field.hasError, + focusNode: focusNode, + child: _Editor( + controller: controller, + focusNode: focusNode, + scrollController: scrollController, + ), + ), + Offstage( + offstage: charsLimit == null, + child: VoicesRichTextLimit( + document: controller.document, + charsLimit: charsLimit, + errorMessage: field.errorText, + ), + ), + ], + ); + }, + ); } -class _VoicesRichTextState extends State { - VoicesRichTextController? _controller; - - VoicesRichTextController get _effectiveController { - return widget.controller ?? - (_controller ??= VoicesRichTextController( - document: Document(), - selection: const TextSelection.collapsed(offset: 0), - )); - } - - VoicesRichTextEditModeController? _editModeController; - - VoicesRichTextEditModeController get _effectiveEditModeController { - return widget.editModeController ?? - (_editModeController ??= VoicesRichTextEditModeController()); - } - - FocusNode? _focusNode; - - FocusNode get _effectiveFocusNode { - return widget.focusNode ?? - (_focusNode ??= FocusNode( - canRequestFocus: _effectiveEditModeController.value, - )); - } - - ScrollController? _scrollController; - - ScrollController get _effectiveScrollController { - return (_scrollController ??= ScrollController()); - } - - Document? _observedDocument; - StreamSubscription? _documentChangeSub; - - Document? _preEditDocument; - - @override - void initState() { - super.initState(); - - _effectiveController.addListener(_onControllerChanged); - _effectiveEditModeController.addListener(_onEditModeControllerChanged); - - _updateObservedDocument(); - } - - @override - void didUpdateWidget(covariant VoicesRichText oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.controller == null && oldWidget.controller != null) { - _controller = VoicesRichTextController( - document: oldWidget.controller!.document, - selection: oldWidget.controller!.selection, - ); - } else if (widget.controller != null && oldWidget.controller == null) { - _controller?.removeListener(_onControllerChanged); - _controller?.dispose(); - _controller = null; - } - - if (widget.controller != oldWidget.controller) { - final old = oldWidget.controller ?? _controller; - final current = widget.controller ?? _controller; - - old?.removeListener(_onControllerChanged); - current?.addListener(_onControllerChanged); - - _updateObservedDocument(); - } - - if (widget.editModeController != oldWidget.editModeController) { - final old = oldWidget.editModeController ?? _editModeController; - final current = widget.editModeController ?? _editModeController; - - old?.removeListener(_onEditModeControllerChanged); - current?.addListener(_onEditModeControllerChanged); - } - } - - @override - void dispose() { - _controller?.dispose(); - _controller = null; - - _editModeController?.dispose(); - _editModeController = null; - - _focusNode?.dispose(); - _focusNode = null; - - _scrollController?.dispose(); - _scrollController = null; +class _Title extends StatelessWidget { + final String title; - super.dispose(); - } + const _Title({ + required this.title, + }); @override Widget build(BuildContext context) { - final charsLimit = widget.charsLimit; - - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(left: 24, top: 20, bottom: 20), - child: _TopBar( - title: widget.title, - isEditMode: _effectiveEditModeController.value, - onToggleEditMode: _toggleEditMode, - ), - ), - Offstage( - offstage: !_effectiveEditModeController.value, - child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _Toolbar(controller: _effectiveController), - ), - ), - _EditorDecoration( - isEditMode: _effectiveEditModeController.value, - child: _Editor( - controller: _effectiveController, - focusNode: _effectiveFocusNode, - scrollController: _effectiveScrollController, - ), - ), - Offstage( - offstage: charsLimit == null, - child: VoicesRichTextLimit( - document: _effectiveController.document, - charsLimit: charsLimit, - ), - ), - const SizedBox(height: 16), - Offstage( - offstage: !_effectiveEditModeController.value, - child: _Footer( - onSave: _saveDocument, - ), - ), - if (!_effectiveEditModeController.value) const SizedBox(height: 24), - ], - ); - } - - Future _toggleEditMode() async { - if (!_effectiveEditModeController.value) { - if (!widget.canEditDocumentGetter(_effectiveController.document)) { - widget.onEditBlocked?.call(); - return; - } - } - - if (_effectiveEditModeController.value) { - _stopEdit(); - } else { - _startEdit(); - } - } - - void _saveDocument() { - _preEditDocument = null; - _effectiveEditModeController.value = false; - - widget.onSaved?.call(_effectiveController.document); - } - - void _startEdit() { - final currentDocument = _effectiveController.document; - _preEditDocument = Document.fromDelta(currentDocument.toDelta()); - _effectiveEditModeController.value = true; - } - - void _stopEdit() { - final preEditDocument = _preEditDocument; - _preEditDocument = null; - _effectiveEditModeController.value = false; - - if (preEditDocument != null) { - _effectiveController.document = preEditDocument; - } - } - - void _onControllerChanged() { - if (_observedDocument != _effectiveController.document) { - _updateObservedDocument(); - } - } - - void _onEditModeControllerChanged() { - setState(() { - _effectiveFocusNode.canRequestFocus = _effectiveEditModeController.value; - }); - } - - void _onDocumentChanged(DocChange change) { - _enforceChatLimit(); - } - - void _updateObservedDocument() { - _observedDocument = _effectiveController.document; - unawaited(_documentChangeSub?.cancel()); - _documentChangeSub = _observedDocument?.changes.listen(_onDocumentChanged); - } - - void _enforceChatLimit() { - final charsLimit = widget.charsLimit; - if (charsLimit != null) { - _clipDocument(charsLimit); - } - } - - void _clipDocument(int limit) { - final documentLength = _effectiveController.document.length; - final latestIndex = limit - 1; - - _effectiveController.replaceText( - latestIndex, - documentLength - limit, - '', - TextSelection.collapsed(offset: latestIndex), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall, + ), ); } } class _EditorDecoration extends StatelessWidget { final bool isEditMode; + final bool isInvalid; + final FocusNode focusNode; final Widget child; const _EditorDecoration({ required this.isEditMode, + required this.isInvalid, + required this.focusNode, required this.child, }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - // TODO(jakub): enable after implementing https://github.com/input-output-hk/catalyst-voices/issues/846 - // child: ResizableBoxParent( - // minHeight: 470, - // resizableVertically: true, - // resizableHorizontally: false, - child: DecoratedBox( - decoration: BoxDecoration( - color: isEditMode - ? Theme.of(context).colors.onSurfaceNeutralOpaqueLv1 - : Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - borderRadius: BorderRadius.circular(8), - ), - child: IgnorePointer( - ignoring: !isEditMode, - child: child, + return + // TODO(jakub): enable after implementing https://github.com/input-output-hk/catalyst-voices/issues/846 + // ResizableBoxParent( + // minHeight: 470, + // resizableVertically: true, + // resizableHorizontally: false, + // child: + DecoratedBox( + decoration: BoxDecoration( + color: isEditMode + ? Theme.of(context).colors.onSurfaceNeutralOpaqueLv1 + : Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, + border: Border.all( + color: _getBorderColor(context), + width: 2, ), + borderRadius: BorderRadius.circular(8), ), - // ), + child: child, ); + // ), + } + + Color _getBorderColor(BuildContext context) { + if (!isEditMode) { + return Theme.of(context).colorScheme.outlineVariant; + } else { + if (isInvalid) { + return Theme.of(context).colorScheme.error; + } + if (focusNode.hasFocus) { + return Theme.of(context).colorScheme.primary; + } + return Theme.of(context).colorScheme.outlineVariant; + } } } @@ -334,6 +153,8 @@ class _Editor extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; return QuillEditor( controller: controller, focusNode: focusNode, @@ -341,6 +162,16 @@ class _Editor extends StatelessWidget { configurations: QuillEditorConfigurations( padding: const EdgeInsets.all(16), placeholder: context.l10n.placeholderRichText, + customStyles: DefaultStyles( + placeHolder: DefaultTextBlockStyle( + textTheme.bodyLarge?.copyWith(color: theme.colors.textDisabled) ?? + DefaultTextStyle.of(context).style, + HorizontalSpacing.zero, + VerticalSpacing.zero, + VerticalSpacing.zero, + null, + ), + ), embedBuilders: CatalystPlatform.isWeb ? FlutterQuillEmbeds.editorWebBuilders() : FlutterQuillEmbeds.editorBuilders(), @@ -349,30 +180,6 @@ class _Editor extends StatelessWidget { } } -class _Footer extends StatelessWidget { - final VoidCallback? onSave; - - const _Footer({ - this.onSave, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - alignment: Alignment.centerRight, - color: Theme.of(context).colors.onSurfaceNeutralOpaqueLv1, - child: VoicesFilledButton( - onTap: onSave, - child: Text(context.l10n.saveButtonText.toUpperCase()), - ), - ); - } -} - class _Toolbar extends StatelessWidget { final QuillController controller; @@ -496,43 +303,6 @@ class _ToolbarIconButton extends StatelessWidget { } } -class _TopBar extends StatelessWidget { - final String title; - final bool isEditMode; - final VoidCallback? onToggleEditMode; - - const _TopBar({ - required this.title, - required this.isEditMode, - this.onToggleEditMode, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - const SizedBox(width: 16), - VoicesTextButton( - onTap: onToggleEditMode, - child: Text( - isEditMode - ? context.l10n.cancelButtonText - : context.l10n.editButtonText, - style: Theme.of(context).textTheme.labelSmall, - ), - ), - const SizedBox(width: 16), - ], - ); - } -} - extension on QuillController { bool get isHeaderSelected { return getSelectionStyle().attributes.containsKey('header'); diff --git a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart index 5153c124815..304057cab58 100644 --- a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart +++ b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart @@ -1,17 +1,18 @@ import 'dart:async'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; class VoicesRichTextLimit extends StatefulWidget { final Document document; final int? charsLimit; + final String? errorMessage; const VoicesRichTextLimit({ super.key, required this.document, this.charsLimit, + this.errorMessage, }); @override @@ -52,7 +53,7 @@ class _VoicesRichTextLimitState extends State { children: [ Expanded( child: Text( - context.l10n.supportingTextLabelText, + widget.errorMessage ?? '', style: Theme.of(context).textTheme.bodySmall, ), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart index 0fcca9cead6..af52a27c71d 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart @@ -52,6 +52,7 @@ class VoicesDateTimeTextField extends StatelessWidget { validator: validator, decoration: VoicesTextFieldDecoration( suffixIcon: ExcludeFocus(child: suffixIcon), + showStatusSuffixIcon: false, fillColor: theme.colors.elevationsOnSurfaceNeutralLv1Grey, filled: true, enabledBorder: OutlineInputBorder( 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 bc128746f4a..2f78ac58bc2 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 @@ -208,7 +208,6 @@ class _PropertyBuilder extends StatelessWidget { 'by $DocumentBuilderSectionTile', ); - case MultiLineTextEntryMarkdownDefinition(): case MultiSelectDefinition(): case SingleLineTextEntryListDefinition(): case MultiLineTextEntryListMarkdownDefinition(): @@ -276,13 +275,24 @@ class _PropertyBuilder extends StatelessWidget { onChanged: onChanged, );*/ case YesNoChoiceDefinition(): - /*final castProperty = definition.castProperty(property); + /*final castProperty = definition.castProperty(property); return YesNoChoiceWidget( property: castProperty, onChanged: onChanged, isEditMode: isEditMode, isRequired: castProperty.schema.isRequired, );*/ + + case MultiLineTextEntryMarkdownDefinition(): + /*final castProperty = definition.castProperty(property); + final castProperty = definition.castProperty(property); + return MultilineTextEntryMarkdownWidget( + property: castProperty, + onChanged: onChanged, + isEditMode: isEditMode, + isRequired: castProperty.schema.isRequired, + );*/ + // TODO(dtscalac): uncomment tiles when casting works. return Text('${definition.runtimeType} casting problem'); } diff --git a/catalyst_voices/apps/voices/test/widgets/rich_text/voices_rich_text_test.dart b/catalyst_voices/apps/voices/test/widgets/rich_text/voices_rich_text_test.dart index 0465b94d3be..4bb376bf625 100644 --- a/catalyst_voices/apps/voices/test/widgets/rich_text/voices_rich_text_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/rich_text/voices_rich_text_test.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices/widgets/rich_text/voices_rich_text.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,7 +9,16 @@ void main() { group(VoicesRichText, () { testWidgets('renders correctly', (tester) async { // Given - const widget = VoicesRichText(); + final widget = VoicesRichText( + controller: VoicesRichTextController( + document: Document(), + selection: const TextSelection.collapsed(offset: 0), + ), + title: 'title', + enabled: true, + focusNode: FocusNode(), + scrollController: ScrollController(), + ); // When await tester.pumpApp(widget); diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_rich_text_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_rich_text_example.dart index ab837a6dc35..4966e50f9bc 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_rich_text_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_rich_text_example.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:catalyst_voices/widgets/rich_text/voices_rich_text.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; @@ -42,7 +40,9 @@ class _VoicesRichTextExampleState extends State { title: 'Rich text', controller: _controller, charsLimit: 800, - onSaved: (document) => log('Saved document: $document'), + enabled: true, + focusNode: FocusNode(), + scrollController: ScrollController(), ), ), );