diff --git a/catalyst_voices/.gitignore b/catalyst_voices/.gitignore index a77786aa472..870c49c28a0 100644 --- a/catalyst_voices/.gitignore +++ b/catalyst_voices/.gitignore @@ -1,6 +1,7 @@ ### Dart ### # See https://www.dartlang.org/guides/libraries/private-files + # Generated files from code generation tools *.g.dart *.freezed.dart @@ -8,6 +9,7 @@ *.swagger.dart *.openapi.dart *.gen.dart +*.swagger.*.dart # Un-ignore generated files in public packages !**/packages/libs/**/*.g.dart @@ -149,4 +151,6 @@ coverage/ **/fastlane/test_output # Fastlane.swift runner binary -**/fastlane/FastlaneRunner \ No newline at end of file +**/fastlane/FastlaneRunner + +devtools_options.yaml \ No newline at end of file diff --git a/catalyst_voices/Earthfile b/catalyst_voices/Earthfile index 1a854036125..8193f80dc8b 100644 --- a/catalyst_voices/Earthfile +++ b/catalyst_voices/Earthfile @@ -18,13 +18,32 @@ builder: COPY +repo-catalyst-voices/repo . DO flutter-ci+BOOTSTRAP +# Creates filtered OpenAPI spec +# Takes json file from openapi-filter from /packages/internal/catalyst_voices_services +filter-openapi: + FROM node:18 + WORKDIR /packages/internal/catalyst_voices_services + + COPY catalyst-gateway+build/doc/cat-gateway-api.json openapi/cat-gateway-api.json + COPY packages/internal/catalyst_voices_services/openapi-filters.json openapi-filters.json + + RUN npm install -g openapi-format + RUN openapi-format openapi/cat-gateway-api.json -o openapi/filtered-openapi.json --filterFile openapi-filters.json --verbose + + RUN rm openapi/cat-gateway-api.json + + SAVE ARTIFACT openapi/filtered-openapi.json + # Generates flutter code. # Generates codes for Catalyst Gateway OpenAPI, Voices Localization and # VoicesAssets and other packages that depend on code-generator. # It accepts [save_locally] ARG that when true place the artifacts in the # proper folders +# It accepts [filter_openapi] ARG that when true filter the openapi spec +# using filters from /packages/internal/catalyst_voices_services/openapi-filters.json code-generator: - ARG save_locally=false + ARG save_locally=false + ARG filter_openapi=true FROM +builder LET gen_code_path = lib/generated/catalyst_gateway LET local_gen_code_path = packages/internal/catalyst_voices_services/lib/generated/catalyst_gateway/ @@ -33,16 +52,22 @@ code-generator: RUN melos build_runner IF [ $save_locally = true ] - RUN find . \( -name "*.g.dart" -o -name "*.freezed.dart" -o -name "*.chopper.dart" -o -name "*.swagger.dart" -o -name "*.openapi.dart" -o -name "*.gen.dart" -o -name "catalyst_voices_localizations*.dart" \) + RUN find . \( -name "*.g.dart" -o -name "*.freezed.dart" -o -name "*.chopper.dart" -o -name "*.swagger.dart" -o -name "*.openapi.dart" -o -name "*.gen.dart" -o -name "catalyst_voices_localizations*.dart" -o -name "cat_gateway_api.*.swagger.*" \) - FOR generated_file IN $(find . \( -name "*.g.dart" -o -name "*.freezed.dart" -o -name "*.chopper.dart" -o -name "*.swagger.dart" -o -name "*.openapi.dart" -o -name "*.gen.dart" -o -name "catalyst_voices_localizations*.dart" \)) + FOR generated_file IN $(find . \( -name "*.g.dart" -o -name "*.freezed.dart" -o -name "*.chopper.dart" -o -name "*.swagger.dart" -o -name "*.openapi.dart" -o -name "*.gen.dart" -o -name "catalyst_voices_localizations*.dart" -o -name "cat_gateway_api.*.swagger.*" \)) SAVE ARTIFACT $generated_file AS LOCAL $generated_file END ELSE SAVE ARTIFACT . END WORKDIR packages/internal/catalyst_voices_services - COPY catalyst-gateway+build/doc/cat-gateway-api.json openapi/cat-gateway-api.json + + IF [ $filter_openapi = true ] + COPY +filter-openapi/filtered-openapi.json openapi/cat-gateway-api.json + ELSE + COPY catalyst-gateway+build/doc/cat-gateway-api.json openapi/cat-gateway-api.json + END + DO flutter-ci+OPENAPI_CODE_GEN \ --SAVE_LOCALLY=$save_locally \ --GEN_CODE_PATH=$gen_code_path \ diff --git a/catalyst_voices/apps/voices/integration_test/Earthfile b/catalyst_voices/apps/voices/integration_test/Earthfile index 822e0559415..75f0f3de6c8 100644 --- a/catalyst_voices/apps/voices/integration_test/Earthfile +++ b/catalyst_voices/apps/voices/integration_test/Earthfile @@ -46,7 +46,10 @@ integration-test-web: exit 1 END -test-web-all: +# TODO(dtscalac): disabled integration tests due to them being flaky, +# reenable when enable-threads.js workaround is removed +# and https://github.com/fzyzcjy/flutter_rust_bridge/issues/2407 closed +disabled-test-web-all: BUILD +integration-test-web \ --browser=chrome \ --browser=firefox diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_rich_text_step.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_rich_text_step.dart index ed205aa67e0..9d5814abbc7 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_rich_text_step.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_rich_text_step.dart @@ -1,11 +1,12 @@ import 'package:catalyst_voices/widgets/navigation/section_step_state_builder.dart'; import 'package:catalyst_voices/widgets/rich_text/voices_rich_text.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; -class WorkspaceRichTextStep extends StatelessWidget { +class WorkspaceRichTextStep extends StatefulWidget { final RichTextStep step; const WorkspaceRichTextStep({ @@ -13,10 +14,40 @@ class WorkspaceRichTextStep extends StatelessWidget { required this.step, }); + @override + State createState() => _WorkspaceRichTextStepState(); +} + +class _WorkspaceRichTextStepState extends State { + late final VoicesRichTextController _controller; + late final VoicesRichTextEditModeController _editModeController; + + @override + void initState() { + super.initState(); + + final document = Document.fromJson(widget.step.data.value); + final selectionOffset = document.length == 0 ? 0 : document.length - 1; + + _controller = VoicesRichTextController( + document: document, + selection: TextSelection.collapsed(offset: selectionOffset), + ); + _editModeController = VoicesRichTextEditModeController(); + _editModeController.addListener(_onEditModeControllerChanged); + } + + @override + void dispose() { + _editModeController.dispose(); + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SectionStepStateBuilder( - id: step.sectionStepId, + id: widget.step.sectionStepId, builder: (context, value, child) { return WorkspaceTileContainer( isSelected: value.isSelected, @@ -24,10 +55,48 @@ class WorkspaceRichTextStep extends StatelessWidget { ); }, child: VoicesRichText( - title: step.localizedDesc(context), - document: Document.fromJson(step.data.value), - charsLimit: step.charsLimit, + title: widget.step.localizedDesc(context), + controller: _controller, + editModeController: _editModeController, + charsLimit: widget.step.charsLimit, + canEditDocumentGetter: _canEditDocument, + onEditBlocked: _showEditBlockedRationale, ), ); } + + bool _canEditDocument(Document document) { + final sectionsController = SectionsControllerScope.of(context); + + final ids = sectionsController.value.editStepsIds; + final isEditing = ids.isNotEmpty; + + return !isEditing; + } + + Future _showEditBlockedRationale() async { + await VoicesDialog.show( + context: context, + builder: (context) { + return VoicesAlertDialog( + title: Text(context.l10n.warning), + subtitle: Text(context.l10n.saveBeforeEditingErrorText), + buttons: [ + VoicesFilledButton( + child: Text(context.l10n.ok), + onTap: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } + + void _onEditModeControllerChanged() { + final isEditMode = _editModeController.value; + final sectionsController = SectionsControllerScope.of(context); + final id = widget.step.sectionStepId; + + sectionsController.editStep(id, enabled: isEditMode); + } } diff --git a/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart b/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart index 2334cb80177..2de195aff90 100644 --- a/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart +++ b/catalyst_voices/apps/voices/lib/widgets/navigation/sections_controller.dart @@ -11,23 +11,13 @@ final class SectionsControllerState extends Equatable { final List
sections; final Set openedSections; final SectionStepId? activeStepId; + final Set editStepsIds; - factory SectionsControllerState({ - List
sections = const [], - Set openedSections = const {}, - SectionStepId? activeStepId, - }) { - return SectionsControllerState._( - sections: sections, - openedSections: openedSections, - activeStepId: activeStepId, - ); - } - - const SectionsControllerState._({ + const SectionsControllerState({ this.sections = const [], this.openedSections = const {}, this.activeStepId, + this.editStepsIds = const {}, }); int? get activeSectionId => activeStepId?.sectionId; @@ -70,20 +60,22 @@ final class SectionsControllerState extends Equatable { List
? sections, Set? openedSections, Optional? activeStepId, + Set? editStepsIds, }) { return SectionsControllerState( sections: sections ?? this.sections, openedSections: openedSections ?? this.openedSections, activeStepId: activeStepId.dataOr(this.activeStepId), + editStepsIds: editStepsIds ?? this.editStepsIds, ); } @override List get props => [ sections, - listItems, openedSections, activeStepId, + editStepsIds, ]; } @@ -91,7 +83,7 @@ final class SectionsController extends ValueNotifier { ItemScrollController? _itemsScrollController; SectionsController([ - super.value = const SectionsControllerState._(), + super.value = const SectionsControllerState(), ]) : super(); // ignore: use_setters_to_change_properties @@ -145,6 +137,26 @@ final class SectionsController extends ValueNotifier { unawaited(_scrollToSection(id)); } + void editStep( + SectionStepId id, { + required bool enabled, + }) { + final editStepsIds = {...value.editStepsIds}; + Optional? activeStepId; + + if (enabled) { + editStepsIds.add(id); + activeStepId = Optional.of(id); + } else { + editStepsIds.remove(id); + } + + value = value.copyWith( + editStepsIds: editStepsIds, + activeStepId: activeStepId, + ); + } + @override void dispose() { detachItemsScrollController(); 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 964c6eb5707..848796f5364 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,5 +1,8 @@ +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'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; @@ -8,21 +11,45 @@ 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, + required super.selection, + }); +} + +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 { final String title; - final Document? document; - final ValueChanged? onSave; + final VoicesRichTextController? controller; + final VoicesRichTextEditModeController? editModeController; + final FocusNode? focusNode; final int? charsLimit; + final CanEditDocumentGetter canEditDocumentGetter; + final VoidCallback? onEditBlocked; + final ValueChanged? onSaved; const VoicesRichText({ super.key, this.title = '', - this.document, - this.onSave, + this.controller, + this.editModeController, + this.focusNode, this.charsLimit, + this.canEditDocumentGetter = _alwaysAllowEdit, + this.onEditBlocked, + this.onSaved, }); @override @@ -30,118 +57,239 @@ class VoicesRichText extends StatefulWidget { } class _VoicesRichTextState extends State { - final QuillController _controller = QuillController.basic(); - int _documentLength = 0; - bool _editMode = false; - Document _preEditDocument = Document(); - final FocusNode _focusNode = FocusNode(); + 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; + + super.dispose(); + } @override Widget build(BuildContext context) { + final charsLimit = widget.charsLimit; + return Column( children: [ Padding( - padding: const EdgeInsets.only( - left: 24, - top: 20, - bottom: 20, - ), + padding: const EdgeInsets.only(left: 24, top: 20, bottom: 20), child: _TopBar( title: widget.title, - editMode: _editMode, - onToggleEditMode: () { - setState(() { - if (_editMode) { - _controller.document = - Document.fromDelta(_preEditDocument.toDelta()); - } else { - _preEditDocument = - Document.fromDelta(_controller.document.toDelta()); - } - _editMode = !_editMode; - }); - }, + isEditMode: _effectiveEditModeController.value, + onToggleEditMode: _toggleEditMode, ), ), - if (_editMode) - Padding( + Offstage( + offstage: !_effectiveEditModeController.value, + child: Padding( padding: const EdgeInsets.only(bottom: 16), - child: _Toolbar(controller: _controller), + child: _Toolbar(controller: _effectiveController), ), - _Editor( - editMode: _editMode, - controller: _controller, - focusNode: _focusNode, ), - if (widget.charsLimit != null) - _Limit( - documentLength: _documentLength, - charsLimit: widget.charsLimit!, + _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), - if (_editMode) - _Footer( - controller: _controller, - onSave: (document) { - widget.onSave?.call(document); - setState(() { - _editMode = false; - }); - }, - ) - else - const SizedBox(height: 24), + Offstage( + offstage: !_effectiveEditModeController.value, + child: _Footer( + onSave: _saveDocument, + ), + ), + if (!_effectiveEditModeController.value) const SizedBox(height: 24), ], ); } - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); + Future _toggleEditMode() async { + if (!_effectiveEditModeController.value) { + if (!widget.canEditDocumentGetter(_effectiveController.document)) { + widget.onEditBlocked?.call(); + return; + } + } + + if (_effectiveEditModeController.value) { + _stopEdit(); + } else { + _startEdit(); + } } - @override - void initState() { - super.initState(); - if (widget.document != null) _controller.document = widget.document!; - _controller.document.changes.listen(_onDocumentChange); - _documentLength = _controller.document.length; + 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 _onDocumentChange(DocChange docChange) { - final documentLength = _controller.document.length; + void _onControllerChanged() { + if (_observedDocument != _effectiveController.document) { + _updateObservedDocument(); + } + } + void _onEditModeControllerChanged() { setState(() { - _documentLength = documentLength; + _effectiveFocusNode.canRequestFocus = _effectiveEditModeController.value; }); + } - final limit = widget.charsLimit; + void _onDocumentChanged(DocChange change) { + _enforceChatLimit(); + } - if (limit == null) return; + void _updateObservedDocument() { + _observedDocument = _effectiveController.document; + unawaited(_documentChangeSub?.cancel()); + _documentChangeSub = _observedDocument?.changes.listen(_onDocumentChanged); + } - if (documentLength > limit) { - final latestIndex = limit - 1; - _controller.replaceText( - latestIndex, - documentLength - limit, - '', - TextSelection.collapsed(offset: latestIndex), - ); + 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), + ); + } } -class _Editor extends StatelessWidget { - final bool editMode; - final QuillController controller; - final FocusNode focusNode; +class _EditorDecoration extends StatelessWidget { + final bool isEditMode; + final Widget child; - const _Editor({ - required this.editMode, - required this.controller, - required this.focusNode, + const _EditorDecoration({ + required this.isEditMode, + required this.child, }); @override @@ -155,7 +303,7 @@ class _Editor extends StatelessWidget { // resizableHorizontally: false, child: DecoratedBox( decoration: BoxDecoration( - color: editMode + color: isEditMode ? Theme.of(context).colors.onSurfaceNeutralOpaqueLv1 : Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, border: Border.all( @@ -164,18 +312,8 @@ class _Editor extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: IgnorePointer( - ignoring: !editMode, - child: QuillEditor.basic( - controller: controller, - focusNode: focusNode, - configurations: QuillEditorConfigurations( - padding: const EdgeInsets.all(16), - placeholder: context.l10n.placeholderRichText, - embedBuilders: CatalystPlatform.isWeb - ? FlutterQuillEmbeds.editorWebBuilders() - : FlutterQuillEmbeds.editorBuilders(), - ), - ), + ignoring: !isEditMode, + child: child, ), ), // ), @@ -183,12 +321,38 @@ class _Editor extends StatelessWidget { } } +class _Editor extends StatelessWidget { + final VoicesRichTextController controller; + final FocusNode focusNode; + final ScrollController scrollController; + + const _Editor({ + required this.controller, + required this.focusNode, + required this.scrollController, + }); + + @override + Widget build(BuildContext context) { + return QuillEditor( + controller: controller, + focusNode: focusNode, + scrollController: scrollController, + configurations: QuillEditorConfigurations( + padding: const EdgeInsets.all(16), + placeholder: context.l10n.placeholderRichText, + embedBuilders: CatalystPlatform.isWeb + ? FlutterQuillEmbeds.editorWebBuilders() + : FlutterQuillEmbeds.editorBuilders(), + ), + ); + } +} + class _Footer extends StatelessWidget { - final QuillController controller; - final ValueChanged? onSave; + final VoidCallback? onSave; const _Footer({ - required this.controller, this.onSave, }); @@ -202,39 +366,8 @@ class _Footer extends StatelessWidget { alignment: Alignment.centerRight, color: Theme.of(context).colors.onSurfaceNeutralOpaqueLv1, child: VoicesFilledButton( + onTap: onSave, child: Text(context.l10n.saveButtonText.toUpperCase()), - onTap: () => onSave?.call(controller.document), - ), - ); - } -} - -class _Limit extends StatelessWidget { - final int documentLength; - final int charsLimit; - - const _Limit({ - required this.documentLength, - required this.charsLimit, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Row( - children: [ - Expanded( - child: Text( - context.l10n.supportingTextLabelText, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - Text( - '$documentLength/$charsLimit', - style: Theme.of(context).textTheme.bodySmall, - ), - ], ), ); } @@ -251,6 +384,7 @@ class _Toolbar extends StatelessWidget { Widget build(BuildContext context) { return Container( color: Theme.of(context).colors.onSurfaceNeutralOpaqueLv1, + padding: const EdgeInsets.symmetric(horizontal: 18), child: QuillToolbar( configurations: const QuillToolbarConfigurations(), child: Row( @@ -364,12 +498,12 @@ class _ToolbarIconButton extends StatelessWidget { class _TopBar extends StatelessWidget { final String title; - final bool editMode; + final bool isEditMode; final VoidCallback? onToggleEditMode; const _TopBar({ required this.title, - required this.editMode, + required this.isEditMode, this.onToggleEditMode, }); @@ -385,7 +519,7 @@ class _TopBar extends StatelessWidget { VoicesTextButton( onTap: onToggleEditMode, child: Text( - editMode + isEditMode ? context.l10n.cancelButtonText : context.l10n.editButtonText, style: Theme.of(context).textTheme.labelSmall, 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 new file mode 100644 index 00000000000..5153c124815 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart @@ -0,0 +1,81 @@ +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; + + const VoicesRichTextLimit({ + super.key, + required this.document, + this.charsLimit, + }); + + @override + State createState() => _VoicesRichTextLimitState(); +} + +class _VoicesRichTextLimitState extends State { + StreamSubscription? _docChangesSub; + + @override + void initState() { + super.initState(); + _docChangesSub = widget.document.changes.listen(_updateDocLength); + } + + @override + void didUpdateWidget(covariant VoicesRichTextLimit oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.document != oldWidget.document) { + unawaited(_docChangesSub?.cancel()); + _docChangesSub = widget.document.changes.listen(_updateDocLength); + } + } + + @override + void dispose() { + unawaited(_docChangesSub?.cancel()); + _docChangesSub = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.supportingTextLabelText, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Text( + _formatText(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + String _formatText() { + final charsLimit = widget.charsLimit; + final documentLength = widget.document.length; + if (charsLimit == null) { + return '$documentLength'; + } + + return '$documentLength/$charsLimit'; + } + + void _updateDocLength(DocChange change) { + setState(() {}); + } +} 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 cee75b5d567..1b6dd0ca5bd 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 @@ -136,18 +136,8 @@ "@snackbarErrorMessageText": { "description": "Text shown in the Snackbar widget when the message is an error message." }, - "snackbarRefreshButtonText": "Refresh", - "@snackbarRefreshButtonText": { - "description": "Text shown in the Snackbar widget for the refresh button." - }, - "snackbarMoreButtonText": "Learn more", - "@snackbarMoreButtonText": { - "description": "Text shown in the Snackbar widget for the more button." - }, - "snackbarOkButtonText": "Ok", - "@snackbarOkButtonText": { - "description": "Text shown in the Snackbar widget for the ok button." - }, + "refresh": "Refresh", + "ok": "Ok", "seedPhraseSlotNr": "Slot {nr}", "@seedPhraseSlotNr": { "description": "When user arranges seed phrases this text is shown when phrase was not selected", @@ -967,5 +957,6 @@ "reviewRegistrationTransaction": "Review registration transaction", "@reviewRegistrationTransaction": { "description": "A button label to review the registration transaction in wallet detail panel." - } + }, + "saveBeforeEditingErrorText": "Please save before editing something else" } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/openapi-filters.json b/catalyst_voices/packages/internal/catalyst_voices_services/openapi-filters.json new file mode 100644 index 00000000000..2e2a9db2c4b --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_services/openapi-filters.json @@ -0,0 +1,17 @@ +{ + "inverseTags": [ + "Health", + "Legacy" + ], + "operations": [ + "*::/api/draft/*" + ], + "unusedComponents": [ + "schemas", + "parameters", + "examples", + "headers", + "requestBodies", + "responses" + ] +} \ No newline at end of file diff --git a/catalyst_voices/utilities/uikit_example/Earthfile b/catalyst_voices/utilities/uikit_example/Earthfile index ec37290a2e5..fd8241806c2 100644 --- a/catalyst_voices/utilities/uikit_example/Earthfile +++ b/catalyst_voices/utilities/uikit_example/Earthfile @@ -7,7 +7,7 @@ IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.24 AS flutter # Prefixed by "local" to make sure it's not auto triggered, the target was # designed to work with a specific github action that needs the target output files local-build-web: - FROM catalyst-voices+builder + FROM catalyst-voices+code-generator ARG WORKDIR=/frontend/utilities/uikit_example DO flutter-ci+BUILD_WEB --TARGET=lib/main.dart --WORKDIR=$WORKDIR SAVE ARTIFACT web AS LOCAL public \ No newline at end of file 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 b8a50812a19..ab837a6dc35 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 @@ -5,11 +5,33 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; -class VoicesRichTextExample extends StatelessWidget { +class VoicesRichTextExample extends StatefulWidget { static const String route = '/rich-text-example'; const VoicesRichTextExample({super.key}); + @override + State createState() => _VoicesRichTextExampleState(); +} + +class _VoicesRichTextExampleState extends State { + late final VoicesRichTextController _controller; + + @override + void initState() { + super.initState(); + _controller = VoicesRichTextController( + document: Document.fromJson(_textSample), + selection: const TextSelection.collapsed(offset: 0), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -18,9 +40,9 @@ class VoicesRichTextExample extends StatelessWidget { body: SingleChildScrollView( child: VoicesRichText( title: 'Rich text', - document: Document.fromJson(_textSample), + controller: _controller, charsLimit: 800, - onSave: (document) => log('Saved document: $document'), + onSaved: (document) => log('Saved document: $document'), ), ), ); diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_snackbar_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_snackbar_example.dart index 79cf16e14e5..4bec37625a0 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_snackbar_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_snackbar_example.dart @@ -15,7 +15,7 @@ class VoicesSnackbarExample extends StatelessWidget { [ VoicesSnackBarPrimaryAction( onPressed: () {}, - child: Text(context.l10n.snackbarRefreshButtonText), + child: Text(context.l10n.refresh), ), VoicesSnackBarSecondaryAction( onPressed: () {},