diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index fc19734..a3ece78 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -22,7 +22,6 @@ jobs: - uses: ZebraDevs/flutter-code-quality@main with: token: ${{secrets.GITHUB_TOKEN}} - run-tests: false run-coverage: false check-secret: @@ -34,7 +33,7 @@ jobs: id: check run: | echo "defined=true" >> $GITHUB_OUTPUT; - if [ "${{ secrets.FIREBASE_SERVICE_ACCOUNT_ZETA_DS }}" == '' ]; then + if [ "${{ secrets.FIREBASE_SERVICE_ACCOUNT_ZDS_FLUTTER }}" == '' ]; then echo "defined=false" >> $GITHUB_OUTPUT; fi diff --git a/example/lib/pages/components/selectable_widget.dart b/example/lib/pages/components/selectable_widget.dart new file mode 100644 index 0000000..1d8e7a9 --- /dev/null +++ b/example/lib/pages/components/selectable_widget.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:zds_flutter/zds_flutter.dart'; + +/// Contains a demonstration of the ZdsSelectableWidget class. +/// This demo showcases how to use the ZdsSelectableWidget with both HTML and plain text content within a Flutter application. +class SelectableWidgetDemo extends StatelessWidget { + const SelectableWidgetDemo({super.key}); + @override + Widget build(BuildContext context) { + String htmlContent = + '''


Birds are a group of

warm-blooded +This paragraph contains a lot of lines in the source code, but the browser ignores it. +This paragraph contains a lot of spaces in the source code, but the browser ignores it. +The number of lines in a paragraph depends on the size of the browser window. If you resize the browser window, the number of lines in this paragraph will change.

+'''; + String plainTextContent = + 'The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.'; + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Column( + children: [ + ZdsListGroup( + padding: EdgeInsets.all(10.0), + headerLabel: Text('For Html Content'), + items: [ + ZdsSelectableWidget( + copyable: true, + textToCopy: htmlContent, + isHtmlData: true, + child: ZdsHtmlContainer( + htmlContent, + showReadMore: false, + onLinkTap: (_, __, ___) { + print('Link tapped'); + }, + ), + ) + ], + ), + ZdsListGroup( + padding: EdgeInsets.all(10.0), + headerLabel: Text('For Plain Text Content'), + items: [ + ZdsSelectableWidget( + copyable: true, + textToCopy: plainTextContent, + isHtmlData: true, + child: Text('$plainTextContent'), + ) + ], + ), + ], + ), + ); + } +} diff --git a/example/lib/routes.dart b/example/lib/routes.dart index 010f29e..1a2aae1 100644 --- a/example/lib/routes.dart +++ b/example/lib/routes.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zds_flutter_example/pages/components/chat.dart'; import 'package:zds_flutter_example/pages/components/comment.dart'; +import 'package:zds_flutter_example/pages/components/selectable_widget.dart'; import 'home.dart'; import 'pages/assets/animations.dart'; @@ -318,6 +319,10 @@ final kRoutes = { title: 'Nested Flow', child: NestedFlowDemo(), ), + const DemoRoute( + title: 'SelectableWidget', + child: SelectableWidgetDemo(), + ), ], 'Assets': [ const DemoRoute( diff --git a/lib/src/components/organisms.dart b/lib/src/components/organisms.dart index db7b594..9762fcf 100644 --- a/lib/src/components/organisms.dart +++ b/lib/src/components/organisms.dart @@ -24,6 +24,7 @@ export 'organisms/properties_list.dart'; export 'organisms/quill_editor/quill.dart'; export 'organisms/radio_list.dart'; export 'organisms/search_app_bar.dart'; +export 'organisms/selectable_widget.dart'; export 'organisms/tab_scaffold.dart'; export 'organisms/tags_list.dart'; export 'organisms/temp_directory/resolver.dart' show clearUiTempDirectory; diff --git a/lib/src/components/organisms/file_picker/file_picker.dart b/lib/src/components/organisms/file_picker/file_picker.dart index e685473..694fb63 100644 --- a/lib/src/components/organisms/file_picker/file_picker.dart +++ b/lib/src/components/organisms/file_picker/file_picker.dart @@ -1056,7 +1056,14 @@ class _MultiInputDialogState extends State<_MultiInputDialog> { widget.textFields[index].value = value; }); }, - onFieldSubmitted: (String value) => widget.textFields[index].value = value, + onFieldSubmitted: (String value) { + setState(() { + widget.textFields[index].value = value; + }); + if (isValid) { + unawaited(Navigator.maybePop(context, widget.textFields)); + } + }, validator: (String? value) => widget.onValidate?.call(widget.textFields[index]), decoration: ZdsInputDecoration( hintText: widget.textFields[index].hint, diff --git a/lib/src/components/organisms/quill_editor/quill_editor.dart b/lib/src/components/organisms/quill_editor/quill_editor.dart index 798c82b..4209ee0 100644 --- a/lib/src/components/organisms/quill_editor/quill_editor.dart +++ b/lib/src/components/organisms/quill_editor/quill_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; +import '../../../../zds_flutter.dart'; import 'quill_toolbar.dart'; /// A custom widget for the Quill editor. @@ -91,7 +92,7 @@ class ZdsQuillEditor extends StatelessWidget { placeholder: placeholder, editorKey: editorKey, ), - ); + ).semantics(identifier: 'TEXT_EDITOR'); // If readOnly, return just editor if (readOnly) return editor; diff --git a/lib/src/components/organisms/selectable_widget.dart b/lib/src/components/organisms/selectable_widget.dart new file mode 100644 index 0000000..9e6c5a5 --- /dev/null +++ b/lib/src/components/organisms/selectable_widget.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +import '../../../zds_flutter.dart'; + +/// A selectable widget that can be used to select the text from the child content on long press. +/// +/// Contains the implementation of the ZdsSelectableWidget class. +/// This widget allows users to select and copy text from its child content on a long press. +/// It supports both plain text and HTML content, converting HTML to plain text if necessary +class ZdsSelectableWidget extends StatefulWidget { + /// Constructor + const ZdsSelectableWidget({super.key, required this.child, required this.textToCopy, this.isHtmlData, this.copyable}); + + /// Child widget + final Widget child; + + /// text to be copied + final String textToCopy; + + /// Whether the copied text is in HTML format (if it is we will convert it to plain text) + final bool? isHtmlData; + + /// Whether the copied text is copyable + final bool? copyable; + + @override + State createState() => _ZdsSelectableWidgetState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('isHtmlData', isHtmlData)) + ..add(DiagnosticsProperty('copyable', copyable)) + ..add(StringProperty('textToCopy', textToCopy)); + } +} + +class _ZdsSelectableWidgetState extends State { + bool isSelected = false; + Timer? _selectionTimer; + + void toggleSelection() { + setState(() { + isSelected = !isSelected; + }); + } + + String htmlToPlainText(String htmlString) { + final dom.Document document = html_parser.parse(htmlString); + return document.body?.text ?? ''; + } + + @override + void dispose() { + _selectionTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!(widget.copyable ?? false)) return widget.child; + final zeta = Zeta.of(context).colors; + return GestureDetector( + child: ColoredBox(color: isSelected ? zeta.primary.surface : Colors.transparent, child: widget.child), + onLongPress: () async { + if (isSelected) return; + toggleSelection(); + if (isSelected) { + var copiedText = widget.textToCopy; + if (widget.isHtmlData ?? false) { + try { + copiedText = htmlToPlainText(widget.textToCopy); + } catch (e) { + copiedText = widget.textToCopy; + } + } + await Clipboard.setData(ClipboardData(text: copiedText)); + ScaffoldMessenger.of(context).showZdsToast( + ZdsToast( + rounded: false, + title: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ComponentStrings.of(context).get('COPIED_TO_CLIPBOARD', 'Copied to Clipboard'), + ), + ], + ), + ), + ), + ); + _selectionTimer = Timer(const Duration(seconds: 4), toggleSelection); + } + }, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('isSelected', isSelected)); + } +} diff --git a/test/fixtures/test_app.dart b/test/fixtures/test_app.dart new file mode 100644 index 0000000..ef72249 --- /dev/null +++ b/test/fixtures/test_app.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:zds_flutter/zds_flutter.dart'; + +class TestApp extends StatelessWidget { + const TestApp({super.key, required this.builder}); + final WidgetBuilder builder; + @override + Widget build(BuildContext context) { + return MaterialApp( + localizationsDelegates: >[ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ComponentDelegate(testing: true), + ], + home: ZetaProvider( + builder: (context, themeData, themeMode) { + return Scaffold(body: builder.call(context)); + }, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty.has('builder', builder)); + } +} diff --git a/test/lib/src/components/organisms/selectable_widget_test.dart b/test/lib/src/components/organisms/selectable_widget_test.dart new file mode 100644 index 0000000..4d28a8a --- /dev/null +++ b/test/lib/src/components/organisms/selectable_widget_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zds_flutter/zds_flutter.dart'; +import '../../../../fixtures/test_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + var clipboardContent = 'Selectable Text'; + setUp(() { + // Mock the clipboard data + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'Clipboard.getData') { + return {'text': clipboardContent}; + } + return null; + }); + }); + tearDown(() { + // Remove the mock handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + group('ZdsSelectableWidget Tests', () { + testWidgets('Displays child widget', (WidgetTester tester) async { + const childWidget = Text('Selectable Text'); + clipboardContent = 'Selectable Text'; + await tester.pumpWidget( + TestApp( + builder: (BuildContext context) { + return const ZdsSelectableWidget( + textToCopy: 'Selectable Text', + child: childWidget, + ); + }, + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byWidget(childWidget), findsOneWidget); + }); + testWidgets('Copies plain text to clipboard on long press', (WidgetTester tester) async { + const childWidget = Text('Selectable Text'); + clipboardContent = 'Selectable Text'; + await tester.pumpWidget( + TestApp( + builder: (BuildContext context) { + return const ZdsSelectableWidget( + textToCopy: 'Selectable Text', + copyable: true, + child: childWidget, + ); + }, + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.longPress(find.byWidget(childWidget)); + await tester.pumpAndSettle(); + final clipboardData = await Clipboard.getData('text/plain'); + expect(clipboardData?.text, clipboardContent); + }); + testWidgets('Copies HTML text to clipboard as plain text on long press', (WidgetTester tester) async { + const childWidget = Text('Selectable HTML Text'); + const htmlText = '

Selectable HTML Text

'; + clipboardContent = 'Selectable HTML Text'; + await tester.pumpWidget( + TestApp( + builder: (BuildContext context) { + return const ZdsSelectableWidget( + textToCopy: htmlText, + isHtmlData: true, + copyable: true, + child: childWidget, + ); + }, + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.longPress(find.byWidget(childWidget)); + await tester.pumpAndSettle(); + final clipboardData = await Clipboard.getData('text/plain'); + expect(clipboardData?.text, 'Selectable HTML Text'); + }); + testWidgets('Does not copy text if copyable is false', (WidgetTester tester) async { + const childWidget = Text('Non-copyable Text'); + clipboardContent = ''; + await tester.pumpWidget( + TestApp( + builder: (BuildContext context) { + return const ZdsSelectableWidget( + textToCopy: 'Non-copyable Text', + copyable: false, + child: childWidget, + ); + }, + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.longPress(find.byWidget(childWidget)); + await tester.pumpAndSettle(); + final clipboardData = await Clipboard.getData('text/plain'); + expect(clipboardData?.text, ''); + }); + }); +}