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 index 4c74206f9a0..431a667a14e 100644 --- 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 @@ -1,8 +1,14 @@ +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/widgets/text_field/voices_text_field.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'; import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:flutter_quill/flutter_quill.dart'; class MultilineTextEntryMarkdownWidget extends StatefulWidget { final DocumentProperty property; @@ -25,21 +31,30 @@ class MultilineTextEntryMarkdownWidget extends StatefulWidget { class _MultilineTextEntryMarkdownWidgetState extends State { - late VoicesRichTextEditModeController _editModeController; - late VoicesRichTextController _controller; + late final VoicesRichTextController _controller; + late final VoicesRichTextFocusNode _focus; + late final ScrollController _scrollController; + + final MarkdownEncoder _encoder = const MarkdownEncoder(); + final MarkdownDecoder _decoder = const MarkdownDecoder(); + + 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 = VoicesRichTextController( - document: quill.Document(), - selection: const TextSelection.collapsed(offset: 0), - ); - _editModeController = VoicesRichTextEditModeController(widget.isEditMode); + _handleInitialValue(); + _controller.addListener(_onControllerChanged); + + _focus = VoicesRichTextFocusNode(); + _scrollController = ScrollController(); } @override @@ -47,17 +62,136 @@ class _MultilineTextEntryMarkdownWidgetState super.didUpdateWidget(oldWidget); if (widget.isEditMode != oldWidget.isEditMode) { - _editModeController.value = widget.isEditMode; + _focus.toggleFocus(enabled: widget.isEditMode); + _controller.readOnly = !widget.isEditMode; + _toggleEditMode(); + } + + if (widget.property.value != oldWidget.property.value) { + _handleInitialValue(); } } + @override + void dispose() { + _controller.dispose(); + _focus.dispose(); + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return VoicesRichText( controller: _controller, - editModeController: _editModeController, + enabled: widget.isEditMode, title: _description, + focusNode: _focus, + scrollController: _scrollController, charsLimit: _maxLength, ); } + + VoicesTextFieldValidationResult _validate(String? value) { + final result = widget.property.schema.validatePropertyValue(value); + if (result.isValid) { + return const VoicesTextFieldValidationResult.success(); + } else { + final localized = LocalizedDocumentValidationResult.from(result); + return VoicesTextFieldValidationResult.error(localized.message(context)); + } + } + + void _handleInitialValue() { + if (_value != null) { + final input = MarkdownData(_value!); + final delta = _decoder.convert(input); + _controller = VoicesRichTextController( + document: quill.Document.fromDelta(delta), + selection: const TextSelection.collapsed(offset: 0), + ); + } else { + _controller = 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(DocChange change) { + _validate(_convertToString()); + if (change.change.last.data != '\n') { + _notifyChangeListener(); + } + } + + void _notifyChangeListener() { + final delta = _controller.document.toDelta(); + final markdownData = _encoder.convert(delta); + widget.onChanged( + DocumentChange( + nodeId: widget.property.schema.nodeId, + value: markdownData.data, + ), + ); + } + + String _convertToString() { + final delta = _controller.document.toDelta(); + final markdownData = _encoder.convert(delta); + return 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; + } + } +} + +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 b3cc3e2948f..44c33435359 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,287 +14,84 @@ 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 StatelessWidget { + final VoicesRichTextController controller; + final bool enabled; 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({ super.key, - this.title = '', - this.controller, - this.editModeController, - this.focusNode, + required this.controller, + required this.enabled, + required this.title, + required this.focusNode, + required this.scrollController, this.charsLimit, - this.canEditDocumentGetter = _alwaysAllowEdit, - this.onEditBlocked, - this.onSaved, }); - @override - State createState() => _VoicesRichTextState(); -} - -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); - } - - // if (widget.editModeController?.value != - // oldWidget.editModeController?.value) { - // _toggleEditMode(); - // } - } - - @override - void dispose() { - _controller?.dispose(); - _controller = null; - - _editModeController?.dispose(); - _editModeController = null; - - _focusNode?.dispose(); - _focusNode = null; - - _scrollController?.dispose(); - _scrollController = null; - - super.dispose(); - } - @override Widget build(BuildContext context) { - final charsLimit = widget.charsLimit; - return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, 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), + offstage: !enabled, + child: _Toolbar( + controller: controller, ), ), - if (widget.title.isNotEmpty) ...[ - Text( - widget.title, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - ], + _Title(title: title), _EditorDecoration( - isEditMode: _effectiveEditModeController.value, + isEditMode: enabled, + isInvalid: false, child: _Editor( - controller: _effectiveController, - focusNode: _effectiveFocusNode, - scrollController: _effectiveScrollController, + controller: controller, + focusNode: focusNode, + scrollController: scrollController, ), ), Offstage( offstage: charsLimit == null, child: VoicesRichTextLimit( - document: _effectiveController.document, + document: controller.document, charsLimit: charsLimit, ), ), - // const SizedBox(height: 16), - // Offstage( - // offstage: !_effectiveEditModeController.value, - // child: _Footer( - // onSave: _saveDocument, - // ), - // ), - // if (!_effectiveEditModeController.value) const SizedBox(height: 24), ], ); } +} - void _toggleEditMode() { - // 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(); - } - } - - Future _onEditModeControllerChanged() async { - 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); - } - } +class _Title extends StatelessWidget { + const _Title({ + required this.title, + }); - void _clipDocument(int limit) { - final documentLength = _effectiveController.document.length; - final latestIndex = limit - 1; + final String title; - _effectiveController.replaceText( - latestIndex, - documentLength - limit, - '', - TextSelection.collapsed(offset: latestIndex), + @override + Widget build(BuildContext context) { + 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 Widget child; const _EditorDecoration({ required this.isEditMode, + required this.isInvalid, required this.child, }); @@ -310,27 +99,36 @@ class _EditorDecoration extends StatelessWidget { Widget build(BuildContext context) { return // TODO(jakub): enable after implementing https://github.com/input-output-hk/catalyst-voices/issues/846 - // child: ResizableBoxParent( + // 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, + color: _getBorderColor(context), + width: 2, ), borderRadius: BorderRadius.circular(8), ), - child: IgnorePointer( - ignoring: !isEditMode, - child: child, - ), + child: child, ); // ), } + + Color _getBorderColor(BuildContext context) { + if (!isEditMode) { + return Theme.of(context).colorScheme.outlineVariant; + } else { + return isInvalid + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.outlineVariant; + } + } } class _Editor extends StatelessWidget { @@ -373,30 +171,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; @@ -520,43 +294,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/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(), ), ), );