diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart index 085df3f28..be6225c84 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart @@ -39,10 +39,9 @@ class EditRequestBody extends ConsumerWidget { child: switch (contentType) { ContentType.formdata => const Padding(padding: kPh4, child: FormDataWidget()), - // TODO: Fix JsonTextFieldEditor & plug it here ContentType.json => Padding( padding: kPt5o10, - child: TextFieldEditor( + child: JsonTextFieldEditor( key: Key("$selectedId-json-body"), fieldKey: "$selectedId-json-body-editor", initialValue: requestModel?.requestBody, diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index 109835286..16213a11a 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -1,7 +1,7 @@ -import 'dart:math' as math; +import 'package:code_text_field/code_text_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:apidash/consts.dart'; +import 'package:highlight/languages/plaintext.dart'; class TextFieldEditor extends StatefulWidget { const TextFieldEditor({ @@ -21,33 +21,29 @@ class TextFieldEditor extends StatefulWidget { class _TextFieldEditorState extends State { final TextEditingController controller = TextEditingController(); late final FocusNode editorFocusNode; - - void insertTab() { - String sp = " "; - int offset = math.min( - controller.selection.baseOffset, controller.selection.extentOffset); - String text = controller.text.substring(0, offset) + - sp + - controller.text.substring(offset); - controller.value = TextEditingValue( - text: text, - selection: controller.selection.copyWith( - baseOffset: controller.selection.baseOffset + sp.length, - extentOffset: controller.selection.extentOffset + sp.length, - ), - ); - widget.onChanged?.call(text); - } + CodeController? _codeController; + bool _focused = false; @override void initState() { super.initState(); editorFocusNode = FocusNode(debugLabel: "Editor Focus Node"); + _codeController = CodeController( + text: widget.initialValue, + language: plaintext, + ); + // listener for changing border color on focus change + editorFocusNode.addListener(() { + setState(() { + _focused = editorFocusNode.hasFocus; + }); + }); } @override void dispose() { editorFocusNode.dispose(); + _codeController?.dispose(); super.dispose(); } @@ -56,51 +52,41 @@ class _TextFieldEditorState extends State { if (widget.initialValue != null) { controller.text = widget.initialValue!; } - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.tab): () { - insertTab(); - }, - }, - child: TextFormField( - key: Key(widget.fieldKey), - controller: controller, + return CodeTheme( + key: Key(widget.fieldKey), + data: CodeThemeData( + styles: Theme.of(context).brightness == Brightness.dark + ? kDarkCodeTheme + : kLightCodeTheme), + child: CodeField( + controller: _codeController!, focusNode: editorFocusNode, keyboardType: TextInputType.multiline, expands: true, maxLines: null, - style: kCodeStyle, - textAlignVertical: TextAlignVertical.top, + textStyle: kCodeStyle, + lineNumbers: false, + hintText: "Enter content (body)", + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.outline.withOpacity( + kHintOpacity, + ), + ), onChanged: widget.onChanged, - decoration: InputDecoration( - hintText: "Enter content (body)", - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.outline.withOpacity( - kHintOpacity, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: kBorderRadius8, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withOpacity( - kHintOpacity, - ), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: kBorderRadius8, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.surfaceVariant, - ), - ), - filled: true, - hoverColor: kColorTransparent, - fillColor: Color.alphaBlend( + decoration: BoxDecoration( + color: Color.alphaBlend( (Theme.of(context).brightness == Brightness.dark ? Theme.of(context).colorScheme.onPrimaryContainer : Theme.of(context).colorScheme.primaryContainer) .withOpacity(kForegroundOpacity), Theme.of(context).colorScheme.surface), + border: Border.fromBorderSide(BorderSide( + color: _focused + ? Theme.of(context).colorScheme.primary.withOpacity( + kHintOpacity, + ) + : Theme.of(context).colorScheme.surfaceVariant)), + borderRadius: kBorderRadius8, ), ), ); diff --git a/lib/widgets/editor_json.dart b/lib/widgets/editor_json.dart index 7779f2971..daef1ce03 100644 --- a/lib/widgets/editor_json.dart +++ b/lib/widgets/editor_json.dart @@ -1,8 +1,8 @@ -import 'dart:math' as math; +import 'package:code_text_field/code_text_field.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:json_text_field/json_text_field.dart'; +import 'package:highlight/languages/json.dart'; import 'package:apidash/consts.dart'; +import 'dart:convert' as convert; class JsonTextFieldEditor extends StatefulWidget { const JsonTextFieldEditor({ @@ -20,109 +20,168 @@ class JsonTextFieldEditor extends StatefulWidget { } class _JsonTextFieldEditorState extends State { - final JsonTextFieldController controller = JsonTextFieldController(); late final FocusNode editorFocusNode; - - void insertTab() { - String sp = " "; - int offset = math.min( - controller.selection.baseOffset, controller.selection.extentOffset); - String text = controller.text.substring(0, offset) + - sp + - controller.text.substring(offset); - controller.value = TextEditingValue( - text: text, - selection: controller.selection.copyWith( - baseOffset: controller.selection.baseOffset + sp.length, - extentOffset: controller.selection.extentOffset + sp.length, - ), - ); - widget.onChanged?.call(text); - } + late bool _focused = false; + CodeController? _codeController; + late String? _jsonError; @override void initState() { super.initState(); - controller.formatJson(sortJson: false); editorFocusNode = FocusNode(debugLabel: "Editor Focus Node"); + _codeController = CodeController( + text: widget.initialValue, + language: json, + ); + // listener for changing border color on focus change + editorFocusNode.addListener(() { + setState(() { + _focused = editorFocusNode.hasFocus; + }); + }); + + // iniialize errors + if (_codeController!.text.isEmpty) { + _jsonError = null; + } else { + _jsonError = getJsonParsingError(_codeController!.text); + } } @override void dispose() { editorFocusNode.dispose(); + _codeController?.dispose(); super.dispose(); } + void _setJsonError(String? error) => setState(() => _jsonError = error); @override Widget build(BuildContext context) { - if (widget.initialValue != null) { - controller.text = widget.initialValue!; - } - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.tab): () { - insertTab(); - }, - }, - child: JsonTextField( - stringHighlightStyle: kCodeStyle.copyWith( - color: Theme.of(context).colorScheme.secondary, - ), - keyHighlightStyle: kCodeStyle.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - errorContainerDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.error.withOpacity( - kForegroundOpacity, - ), - borderRadius: kBorderRadius8, - ), - showErrorMessage: true, - isFormatting: true, - key: Key(widget.fieldKey), - controller: controller, - focusNode: editorFocusNode, - keyboardType: TextInputType.multiline, - expands: true, - maxLines: null, - style: kCodeStyle, - textAlignVertical: TextAlignVertical.top, - onChanged: (value) { - controller.formatJson(sortJson: false); - widget.onChanged?.call(value); - }, - decoration: InputDecoration( - hintText: "Enter content (body)", - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.outline.withOpacity( - kHintOpacity, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: kBorderRadius8, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary.withOpacity( + return Column( + children: [ + Expanded( + child: CodeTheme( + data: CodeThemeData( + styles: Theme.of(context).brightness == Brightness.dark + ? kDarkCodeTheme + : kLightCodeTheme), + child: CodeField( + key: Key(widget.fieldKey), + controller: _codeController!, + focusNode: editorFocusNode, + keyboardType: TextInputType.multiline, + expands: true, + maxLines: null, + textStyle: kCodeStyle, + lineNumbers: false, + hintText: "Enter content (json)", + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.outline.withOpacity( kHintOpacity, ), ), + onChanged: (value) { + widget.onChanged?.call(value); + validateJson(value); + }, + decoration: BoxDecoration( + color: Color.alphaBlend( + (Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.primaryContainer) + .withOpacity(kForegroundOpacity), + Theme.of(context).colorScheme.surface), + border: Border.fromBorderSide(BorderSide( + color: _focused + ? Theme.of(context).colorScheme.primary.withOpacity( + kHintOpacity, + ) + : Theme.of(context).colorScheme.surfaceVariant)), + borderRadius: _jsonError == null + ? kBorderRadius8 + : const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8)), + ), ), - enabledBorder: OutlineInputBorder( - borderRadius: kBorderRadius8, - borderSide: BorderSide( - color: Theme.of(context).colorScheme.surfaceVariant, + )), + _jsonError == null + ? const SizedBox.shrink() + : Container( + padding: kP8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8))), + child: Center( + child: Text( + _jsonError ?? '', + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Padding( + padding: kP8, + child: ElevatedButton( + onPressed: _jsonError != null + ? null + : () { + if (_codeController!.text.isNotEmpty) { + formatJson(); + } + }, + child: const Text( + 'Format JSON', + style: kTextStyleButton, ), ), - filled: true, - hoverColor: kColorTransparent, - fillColor: Color.alphaBlend( - (Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.primaryContainer) - .withOpacity(kForegroundOpacity), - Theme.of(context).colorScheme.surface), ), - ), + ], ); } + + bool isValidJson(String jsonString) { + if (jsonString.isEmpty) return false; + try { + convert.json.decode(jsonString); + return true; + } catch (_) { + return false; + } + } + + String? getJsonParsingError(String? jsonString) { + if (jsonString == null) return null; + + try { + convert.json.decode(jsonString); + return null; + } on FormatException catch (e) { + return e.toString().replaceAll("FormatException: ", ""); + } + } + + void validateJson(String jsonString) { + if (jsonString.isEmpty) { + _setJsonError(null); + return; + } + if (isValidJson(jsonString)) { + _setJsonError(null); + return; + } + _setJsonError(getJsonParsingError(jsonString)); + } + + void formatJson() { + if (!isValidJson(_codeController!.text)) return; + final oldText = _codeController!.text; + var jsonObject = convert.json.decode(oldText); + _codeController!.text = kEncoder.convert(jsonObject); + } } diff --git a/pubspec.lock b/pubspec.lock index d6235dec2..a77541935 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.0" + code_text_field: + dependency: "direct main" + description: + path: "." + ref: "990cde6153567bbbc8a93d89ca110c7a7555a4ab" + resolved-ref: "990cde6153567bbbc8a93d89ca110c7a7555a4ab" + url: "https://github.com/BertrandBev/code_field" + source: git + version: "1.1.1" collection: dependency: "direct main" description: @@ -374,6 +383,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_highlight: + dependency: transitive + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_keyboard_visibility: dependency: transitive description: @@ -536,6 +553,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" highlighter: dependency: "direct main" description: @@ -729,6 +754,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + linked_scroll_controller: + dependency: transitive + description: + name: linked_scroll_controller + sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a + url: "https://pub.dev" + source: hosted + version: "0.2.0" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 528cda82b..0dd9a326d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,11 @@ dependencies: csv: ^6.0.0 data_table_2: ^2.5.11 file_selector: ^1.0.3 + code_text_field: + git: + url: https://github.com/BertrandBev/code_field + ref: 990cde6153567bbbc8a93d89ca110c7a7555a4ab + highlight: ^0.7.0 dependency_overrides: web: ^0.5.0 diff --git a/test/widgets/editor_json_test.dart b/test/widgets/editor_json_test.dart new file mode 100644 index 000000000..0806b2d02 --- /dev/null +++ b/test/widgets/editor_json_test.dart @@ -0,0 +1,128 @@ +import 'package:apidash/widgets/editor_json.dart'; +import 'package:code_text_field/code_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../test_consts.dart'; + +void main() { + testWidgets('Testing JSON Editor', (tester) async { + dynamic changedValue; + await tester.pumpWidget( + MaterialApp( + title: 'JSON Editor', + theme: kThemeDataLight, + home: Scaffold( + body: Column(children: [ + Expanded( + child: JsonTextFieldEditor( + fieldKey: '2', + onChanged: (value) { + changedValue = value; + }, + ), + ), + ]), + ), + ), + ); + + expect(find.byType(CodeField), findsOneWidget); + expect(find.byKey(const Key("2")), findsOneWidget); + expect(find.text('Enter content (json)'), findsOneWidget); + var txtForm = find.byKey(const Key("2")); + await tester.enterText(txtForm, r'''[ + { + "title": "apples", + "count": [12000, 20000], + "description": {"text": "...", "sensitive": false} + }, + { + "title": "oranges", + "count": [17500, null], + "description": {"text": "...", "sensitive": false} + } +]'''); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(txtForm); + await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(changedValue, r'''[ + { + "title": "apples", + "count": [12000, 20000], + "description": {"text": "...", "sensitive": false} + }, + { + "title": "oranges", + "count": [17500, null], + "description": {"text": "...", "sensitive": false} + } +]'''); + }); + testWidgets('Testing Editor Dark theme', (tester) async { + dynamic changedValue; + await tester.pumpWidget( + MaterialApp( + title: 'Editor Dark', + theme: kThemeDataDark, + home: Scaffold( + body: Column(children: [ + Expanded( + child: JsonTextFieldEditor( + fieldKey: '2', + onChanged: (value) { + changedValue = value; + }, + initialValue: 'initial', + ), + ), + ]), + ), + ), + ); + expect(find.text('initial'), findsAtLeast(1)); + expect(find.byType(CodeField), findsOneWidget); + expect(find.byKey(const Key("2")), findsOneWidget); + expect(find.text('Enter content (json)'), findsOneWidget); + var txtForm = find.byKey(const Key("2")); + await tester.enterText(txtForm, r'''[ + { + "title": "apples", + "count": [12000, 20000], + "description": {"text": "...", "sensitive": false} + }, + { + "title": "oranges", + "count": [17500, null], + "description": {"text": "...", "sensitive": false} + } +]'''); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(txtForm); + await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(changedValue, r'''[ + { + "title": "apples", + "count": [12000, 20000], + "description": {"text": "...", "sensitive": false} + }, + { + "title": "oranges", + "count": [17500, null], + "description": {"text": "...", "sensitive": false} + } +]'''); + }); +} diff --git a/test/widgets/editor_test.dart b/test/widgets/editor_test.dart index 238b432c2..84b8e1ee9 100644 --- a/test/widgets/editor_test.dart +++ b/test/widgets/editor_test.dart @@ -1,3 +1,4 @@ +import 'package:code_text_field/code_text_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -26,7 +27,7 @@ void main() { ), ); - expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(CodeField), findsOneWidget); expect(find.byKey(const Key("2")), findsOneWidget); expect(find.text('Enter content (body)'), findsOneWidget); var txtForm = find.byKey(const Key("2")); @@ -40,7 +41,7 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - expect(changedValue, 'entering 123 for testing content body '); + expect(changedValue, 'entering 123 for testing content body'); }); testWidgets('Testing Editor Dark theme', (tester) async { dynamic changedValue; @@ -63,8 +64,8 @@ void main() { ), ), ); - expect(find.text('initial'), findsOneWidget); - expect(find.byType(TextFormField), findsOneWidget); + expect(find.text('initial'), findsAtLeast(1)); + expect(find.byType(CodeField), findsOneWidget); expect(find.byKey(const Key("2")), findsOneWidget); expect(find.text('Enter content (body)'), findsOneWidget); var txtForm = find.byKey(const Key("2")); @@ -78,6 +79,6 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - expect(changedValue, 'entering 123 for testing content body '); + expect(changedValue, 'entering 123 for testing content body'); }); }