diff --git a/lib/screens/common_widgets/env_trigger_field.dart b/lib/screens/common_widgets/env_trigger_field.dart index 3432135f0..bc2902743 100644 --- a/lib/screens/common_widgets/env_trigger_field.dart +++ b/lib/screens/common_widgets/env_trigger_field.dart @@ -18,7 +18,9 @@ class EnvironmentTriggerField extends StatefulWidget { this.optionsWidthFactor, this.autocompleteNoTrigger, this.readOnly = false, - this.obscureText = false + this.obscureText = false, + this.minLines, + this.maxLines, }) : assert( !(controller != null && initialValue != null), 'controller and initialValue cannot be simultaneously defined.', @@ -36,6 +38,8 @@ class EnvironmentTriggerField extends StatefulWidget { final AutocompleteNoTrigger? autocompleteNoTrigger; final bool readOnly; final bool obscureText; + final int? minLines; + final int? maxLines; @override State createState() => @@ -130,8 +134,9 @@ class EnvironmentTriggerFieldState extends State { _focusNode.unfocus(); }, readOnly: widget.readOnly, - obscureText: widget.obscureText - + obscureText: widget.obscureText, + minLines: widget.minLines, + maxLines: widget.maxLines, ); }, ); diff --git a/lib/screens/common_widgets/envfield_url.dart b/lib/screens/common_widgets/envfield_url.dart index 4757c870c..3e1f6673e 100644 --- a/lib/screens/common_widgets/envfield_url.dart +++ b/lib/screens/common_widgets/envfield_url.dart @@ -36,6 +36,7 @@ class EnvURLField extends StatelessWidget { onChanged: onChanged, onFieldSubmitted: onFieldSubmitted, optionsWidthFactor: 1, + maxLines: 1, ); } } diff --git a/lib/screens/home_page/editor_pane/expanded_url_editor.dart b/lib/screens/home_page/editor_pane/expanded_url_editor.dart new file mode 100644 index 000000000..d64ba43ed --- /dev/null +++ b/lib/screens/home_page/editor_pane/expanded_url_editor.dart @@ -0,0 +1,188 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash/screens/common_widgets/env_trigger_field.dart'; + +class ExpandedURLEditor extends StatefulWidget { + const ExpandedURLEditor({ + super.key, + required this.selectedId, + this.initialValue, + this.onChanged, + this.onFieldSubmitted, + }); + + final String selectedId; + final String? initialValue; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + + @override + State createState() => _ExpandedURLEditorState(); +} + +class _ExpandedURLEditorState extends State { + late TextEditingController _controller; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + _focusNode = FocusNode(); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _moveCursorToNextSeparator() { + final text = _controller.text; + final currentPos = _controller.selection.baseOffset; + if (currentPos < 0 || currentPos >= text.length) return; + + final separators = RegExp(r'[:/.,?&=-_]'); + + int nextPos = -1; + for (int i = currentPos + 1; i < text.length; i++) { + if (separators.hasMatch(text[i])) { + nextPos = i; + break; + } + } + + if (nextPos != -1) { + _controller.selection = TextSelection.fromPosition(TextPosition(offset: nextPos)); + } else { + _controller.selection = TextSelection.fromPosition(TextPosition(offset: text.length)); + } + _focusNode.requestFocus(); + } + + void _moveCursorToPreviousSeparator() { + final text = _controller.text; + final currentPos = _controller.selection.baseOffset; + if (currentPos <= 0) return; + + final separators = RegExp(r'[:/.,?&=-_]'); + + int prevPos = -1; + for (int i = currentPos - 1; i >= 0; i--) { + if (separators.hasMatch(text[i])) { + prevPos = i; + break; + } + } + + if (prevPos != -1) { + _controller.selection = TextSelection.fromPosition(TextPosition(offset: prevPos)); + } else { + _controller.selection = TextSelection.fromPosition(const TextPosition(offset: 0)); + } + _focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Theme.of(context).colorScheme.surface, + surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + insetPadding: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, // Important for Dialog + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Edit URL', + style: Theme.of(context).textTheme.titleLarge, + ), + TextButton( + onPressed: () { + widget.onFieldSubmitted?.call(_controller.text); + Navigator.of(context).pop(); + }, + child: const Text('Done'), + ), + ], + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + child: SingleChildScrollView( + child: EnvironmentTriggerField( + keyId: "expanded-url-${widget.selectedId}", + controller: _controller, + focusNode: _focusNode, + style: kCodeStyle.copyWith(fontSize: 16), + decoration: InputDecoration( + hintText: kHintTextUrlCard, + hintStyle: kCodeStyle.copyWith( + color: Theme.of(context).colorScheme.outlineVariant, + fontSize: 16, + ), + border: InputBorder.none, + ), + onChanged: widget.onChanged, + onFieldSubmitted: (val) { + widget.onFieldSubmitted?.call(val); + Navigator.of(context).pop(); + }, + optionsWidthFactor: 1, + maxLines: null, // Allow unlimited lines (wrapping) + minLines: 3, // Minimum height + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton.filledTonal( + onPressed: _moveCursorToPreviousSeparator, + icon: const Icon(Icons.keyboard_arrow_left), + tooltip: 'Previous Separator', + ), + IconButton.filledTonal( + onPressed: _moveCursorToNextSeparator, + icon: const Icon(Icons.keyboard_arrow_right), + tooltip: 'Next Separator', + ), + IconButton.filledTonal( + onPressed: () { + Clipboard.setData(ClipboardData(text: _controller.text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + }, + icon: const Icon(Icons.copy), + tooltip: 'Copy', + ), + IconButton.filledTonal( + onPressed: () { + _controller.clear(); + widget.onChanged?.call(''); + }, + icon: const Icon(Icons.clear), + tooltip: 'Clear', + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/home_page/editor_pane/url_card.dart b/lib/screens/home_page/editor_pane/url_card.dart index d84f27a7f..22763500c 100644 --- a/lib/screens/home_page/editor_pane/url_card.dart +++ b/lib/screens/home_page/editor_pane/url_card.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import '../../common_widgets/common_widgets.dart'; +import 'expanded_url_editor.dart'; class EditorPaneRequestURLCard extends ConsumerWidget { const EditorPaneRequestURLCard({super.key}); @@ -94,13 +95,20 @@ class DropdownButtonHTTPMethod extends ConsumerWidget { } } -class URLTextField extends ConsumerWidget { +class URLTextField extends ConsumerStatefulWidget { const URLTextField({ super.key, }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _URLTextFieldState(); +} + +class _URLTextFieldState extends ConsumerState { + bool _isNavigating = false; + + @override + Widget build(BuildContext context) { final selectedId = ref.watch(selectedIdStateProvider); ref.watch(selectedRequestModelProvider .select((value) => value?.aiRequestModel?.url)); @@ -109,24 +117,54 @@ class URLTextField extends ConsumerWidget { final requestModel = ref .read(collectionStateNotifierProvider.notifier) .getRequestModel(selectedId!)!; - return EnvURLField( - selectedId: selectedId, - initialValue: switch (requestModel.apiType) { - APIType.ai => requestModel.aiRequestModel?.url, - _ => requestModel.httpRequestModel?.url, - }, - onChanged: (value) { - if (requestModel.apiType == APIType.ai) { - ref.read(collectionStateNotifierProvider.notifier).update( - aiRequestModel: - requestModel.aiRequestModel?.copyWith(url: value)); - } else { - ref.read(collectionStateNotifierProvider.notifier).update(url: value); + + final initialValue = switch (requestModel.apiType) { + APIType.ai => requestModel.aiRequestModel?.url, + _ => requestModel.httpRequestModel?.url, + }; + + void updateUrl(String value) { + if (requestModel.apiType == APIType.ai) { + ref.read(collectionStateNotifierProvider.notifier).update( + aiRequestModel: + requestModel.aiRequestModel?.copyWith(url: value)); + } else { + ref.read(collectionStateNotifierProvider.notifier).update(url: value); + } + } + + return GestureDetector( + onScaleUpdate: (details) async { + if (details.scale > 1.5 && !_isNavigating) { + _isNavigating = true; + // HapticFeedback.mediumImpact(); // Optional: Add haptic feedback + await showDialog( + context: context, + builder: (context) => ExpandedURLEditor( + selectedId: selectedId, + initialValue: initialValue, + onChanged: updateUrl, + onFieldSubmitted: (value) { + updateUrl(value); + ref.read(collectionStateNotifierProvider.notifier).sendRequest(); + }, + ), + ); + if (mounted) { + setState(() { + _isNavigating = false; + }); + } } }, - onFieldSubmitted: (value) { - ref.read(collectionStateNotifierProvider.notifier).sendRequest(); - }, + child: EnvURLField( + selectedId: selectedId, + initialValue: initialValue, + onChanged: updateUrl, + onFieldSubmitted: (value) { + ref.read(collectionStateNotifierProvider.notifier).sendRequest(); + }, + ), ); } }