diff --git a/assets/images/dashflow_black.png b/assets/images/dashflow_black.png new file mode 100644 index 000000000..a0069f0af Binary files /dev/null and b/assets/images/dashflow_black.png differ diff --git a/assets/images/dashflow_blue.png b/assets/images/dashflow_blue.png new file mode 100644 index 000000000..d4fd67652 Binary files /dev/null and b/assets/images/dashflow_blue.png differ diff --git a/lib/providers/ui_providers.dart b/lib/providers/ui_providers.dart index 4e5d217f5..1507b3ba1 100644 --- a/lib/providers/ui_providers.dart +++ b/lib/providers/ui_providers.dart @@ -1,6 +1,7 @@ import 'package:apidash/consts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/screens/dashflow/dashflow_builder/nodes.dart'; final mobileScaffoldKeyStateProvider = StateProvider>((ref) => kHomeScaffoldKey); @@ -37,3 +38,47 @@ final environmentSearchQueryProvider = StateProvider((ref) => ''); final importFormatStateProvider = StateProvider((ref) => ImportFormat.curl); final userOnboardedProvider = StateProvider((ref) => false); + +final workflowProvider = StateNotifierProvider>((ref) => WorkflowNotifier()); +final hoverNodeProvider = StateProvider((ref) => null); +final connectionListProvider = StateProvider>((ref) => []); +class WorkflowNotifier extends StateNotifier> { + WorkflowNotifier() : super([ + NodeData(id: 1, offset: Offset(0, 0)), + NodeData(id: 2, offset: Offset(50, 50)), + ]); + + void updateNodeOffset(int id, Offset newOffset) { + state = [ + for (final node in state) + if (node.id == id) node.copyWith(offset: newOffset) else node, + ]; + } + + void addNode(NodeData node) { + state = [...state, node]; + } + + void updateNode(int id, {String? title, String? url, Map? headers}) { + state = [ + for (final node in state) + if (node.id == id) + node.copyWith(title: title, url: url, headers: headers) + else + node, + ]; + } +} + +class ConnectionListNotifier extends StateNotifier> { + ConnectionListNotifier() : super([]); // Initialize with an empty list + + void addConnection(Connection connection) { + state = [...state, connection]; // Add a new connection + } + + void removeConnection(Connection connection) { + state = state.where((c) => c != connection).toList(); // Remove a connection + } +} + diff --git a/lib/screens/collections/collections.dart b/lib/screens/collections/collections.dart new file mode 100644 index 000000000..ce8466e50 --- /dev/null +++ b/lib/screens/collections/collections.dart @@ -0,0 +1,26 @@ +import 'package:apidash/screens/home_page/collection_pane.dart'; +import 'package:apidash/screens/home_page/editor_pane/editor_pane.dart'; +import 'package:apidash/screens/mobile/requests_page/requests_page.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:apidash/widgets/widgets.dart'; + +class CollectionPage extends StatelessWidget { + const CollectionPage({super.key}); + + @override + Widget build(BuildContext context) { + return context.isMediumWindow + ? const RequestResponsePage() + : const Column( + children: [ + Expanded( + child: DashboardSplitView( + sidebarWidget: CollectionPane(), + mainWidget: RequestEditorPane(), + ), + ), + ], + ); + } +} diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 428ffaebc..4ae9cd704 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -1,3 +1,6 @@ +import 'package:apidash/screens/collections/collections.dart'; +import 'package:apidash/screens/dashflow/dashflow.dart'; +import 'package:apidash/screens/monitor/monitor.dart'; import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -68,12 +71,65 @@ class Dashboard extends ConsumerWidget { 'History', style: Theme.of(context).textTheme.labelSmall, ), + kVSpacer10, + IconButton( + isSelected: railIdx == 3, + onPressed: () { + ref.read(navRailIndexStateProvider.notifier).state = 3; + }, + icon: const Icon(Icons.layers_sharp), + selectedIcon: const Icon(Icons.layers_outlined), + ), + Text( + 'Collections', + style: Theme.of(context).textTheme.labelSmall, + ), + kVSpacer10, + IconButton( + isSelected: railIdx == 4, + onPressed: () { + ref.read(navRailIndexStateProvider.notifier).state = 4; + }, + icon: const Icon(Icons.monitor_heart_sharp), + selectedIcon: const Icon(Icons.monitor_heart_outlined), + ), + Text( + 'Monitors', + style: Theme.of(context).textTheme.labelSmall, + ), + kVSpacer10, + IconButton( + isSelected: railIdx == 5, + onPressed: () { + ref.read(navRailIndexStateProvider.notifier).state = 5; + }, + icon: Image.asset("assets/images/dashflow_black.png",height: MediaQuery.of(context).size.height*0.05,), + selectedIcon: Image.asset("assets/images/dashflow_blue.png",height: MediaQuery.of(context).size.height*0.05,), + ), + Text( + 'Dash Flow', + style: Theme.of(context).textTheme.labelSmall, + ), ], ), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: NavbarButton( + railIdx: railIdx, + selectedIcon: Icons.play_circle, + icon: Icons.play_circle_outlined, + label: 'Runner', + showLabel: false, + isCompact: true, + onTap: () { + showAboutAppDialog(context); + }, + ), + ), Padding( padding: const EdgeInsets.only(bottom: 16.0), child: NavbarButton( @@ -118,6 +174,9 @@ class Dashboard extends ConsumerWidget { HomePage(), EnvironmentPage(), HistoryPage(), + CollectionPage(), + MonitorPage(), + DashflowPage(), SettingsPage(), ], ), diff --git a/lib/screens/dashflow/dashflow.dart b/lib/screens/dashflow/dashflow.dart new file mode 100644 index 000000000..548a7d4b4 --- /dev/null +++ b/lib/screens/dashflow/dashflow.dart @@ -0,0 +1,22 @@ +import 'package:apidash/screens/dashflow/dashflow_builder/dashflow_builder.dart'; +import 'package:apidash/screens/dashflow/workflow_pane.dart'; +import 'package:flutter/material.dart'; +import 'package:apidash/widgets/widgets.dart'; + +class DashflowPage extends StatelessWidget { + const DashflowPage({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: DashboardSplitView( + sidebarWidget: WorkflowPane(), + mainWidget: DashflowBuilder(), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/dashflow/dashflow_builder/dashflow_builder.dart b/lib/screens/dashflow/dashflow_builder/dashflow_builder.dart new file mode 100644 index 000000000..0b6fe8405 --- /dev/null +++ b/lib/screens/dashflow/dashflow_builder/dashflow_builder.dart @@ -0,0 +1,23 @@ +import 'package:apidash/screens/dashflow/dashflow_builder/workflow_canvas.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; + +class DashflowBuilder extends ConsumerWidget { + const DashflowBuilder({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedIdStateProvider); + if (selectedId == null) { + return WorkflowCanvas(); + } else { + return WorkflowCanvas(); + } + } +} + + + diff --git a/lib/screens/dashflow/dashflow_builder/grid.dart b/lib/screens/dashflow/dashflow_builder/grid.dart new file mode 100644 index 000000000..b7fc5c144 --- /dev/null +++ b/lib/screens/dashflow/dashflow_builder/grid.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:vector_math/vector_math_64.dart' as vm; + +class GridPainter extends CustomPainter { + final Matrix4 transformation; + final Size viewportSize; + final double baseGridSize; + final double canvasSize; + + GridPainter({ + required this.transformation, + required this.viewportSize, + this.baseGridSize = 20, + required this.canvasSize, + }); + Offset _transformPoint(Offset point, Matrix4 matrix) { + final vector = matrix.transform3(vm.Vector3(point.dx, point.dy, 0)); + return Offset(vector.x, vector.y); + } + + @override + void paint(Canvas canvas, Size size) { + final zoom = transformation.getMaxScaleOnAxis(); + final gridSize = baseGridSize * zoom; + final inverted = Matrix4.copy(transformation)..invert(); + + // Increase grid spacing at low zoom to reduce lines + final effectiveGridSize = zoom < 0.5 + ? gridSize * 4 + : zoom < 1 + ? gridSize * 2 + : gridSize; + + // Center the origin + final centerOffset = Offset(canvasSize / 2, canvasSize / 2); + final topLeft = _transformPoint(Offset.zero, inverted) - centerOffset; + final bottomRight = + _transformPoint(Offset(size.width, size.height), inverted) - + centerOffset; + + // Viewport-relative offsets + final startX = + (topLeft.dx ~/ effectiveGridSize - 1) * effectiveGridSize.toDouble(); + final endX = (bottomRight.dx ~/ effectiveGridSize + 1) * + effectiveGridSize.toDouble(); + + final startY = + (topLeft.dy ~/ effectiveGridSize - 1) * effectiveGridSize.toDouble(); + final endY = (bottomRight.dy ~/ effectiveGridSize + 1) * + effectiveGridSize.toDouble(); + + final paint = Paint() + ..color = Colors.blue.shade700 + ..style = PaintingStyle.fill; + + // Draw dots at grid box corners + for (double x = startX; x <= endX; x += effectiveGridSize) { + for (double y = startY; y <= endY; y += effectiveGridSize) { + canvas.drawCircle( + Offset(x + centerOffset.dx, y + centerOffset.dy), + 1.0, + paint, + ); + } + } + } + + @override + bool shouldRepaint(GridPainter oldDelegate) { + return transformation != oldDelegate.transformation || + viewportSize != oldDelegate.viewportSize || + canvasSize != oldDelegate.canvasSize; + } +} diff --git a/lib/screens/dashflow/dashflow_builder/node_connectors.dart b/lib/screens/dashflow/dashflow_builder/node_connectors.dart new file mode 100644 index 000000000..4952e9741 --- /dev/null +++ b/lib/screens/dashflow/dashflow_builder/node_connectors.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'nodes.dart'; + +class ArrowPainter extends CustomPainter { + final List nodes; + final List connections; + final double gridSize; + final double canvasSize; + final int? hoveredNodeId; // Properly declared as optional + + ArrowPainter({ + required this.nodes, + required this.connections, + required this.gridSize, + required this.canvasSize, + this.hoveredNodeId, + }); + + @override + void paint(Canvas canvas, Size size) { + if (nodes.length < 2) return; + + // Find Node 1 and Node 2 + final sourceNode = nodes.firstWhere((n) => n.id == 1); + final targetNode = nodes.firstWhere((n) => n.id == 2); + final isHighlighted = hoveredNodeId == 1 || hoveredNodeId == 2; + + // Define paint style + final paint = Paint() + ..color = isHighlighted ? Colors.blue : Colors.grey + ..style = PaintingStyle.stroke + ..strokeWidth = isHighlighted ? 3 : 2; + + for (var conn in connections) { + final fromNode = nodes.firstWhere((n) => n.id == conn.from, orElse: () => NodeData(id: -1, offset: Offset.zero)); + final toNode = nodes.firstWhere((n) => n.id == conn.to, orElse: () => NodeData(id: -1, offset: Offset.zero)); + if (fromNode.id == -1 || toNode.id == -1) continue;} + + + // Get rendered sizes using GlobalKey + final sourceRenderBox = + sourceNode.sizeKey.currentContext?.findRenderObject() as RenderBox?; + final targetRenderBox = + targetNode.sizeKey.currentContext?.findRenderObject() as RenderBox?; + if (sourceRenderBox == null || targetRenderBox == null) return; + + final sourceSize = sourceRenderBox.size; + final targetSize = targetRenderBox.size; + + // Define flexible connection points (edges of cards) + final sourceEdgeX = + sourceNode.offset.dx + canvasSize / 2 + sourceSize.width / 2; + final sourceEdgeY = + sourceNode.offset.dy + canvasSize / 2 + sourceSize.height / 2; + final targetEdgeX = + targetNode.offset.dx + canvasSize / 2 + targetSize.width / 2; + final targetEdgeY = + targetNode.offset.dy + canvasSize / 2 + targetSize.height / 2; + + // Z-shaped path with two L-turns, snapped to grid + final midX = ((sourceEdgeX + targetEdgeX) / 2); + final pathPoints = [ + Offset(sourceEdgeX, sourceEdgeY), // Right edge of source node + Offset(midX, sourceEdgeY), // Horizontal to midpoint + Offset(midX, targetEdgeY), // Vertical to target height + Offset(targetEdgeX, targetEdgeY), // Left edge of target node + ]; + + // Draw the path + final path = Path() + ..moveTo(pathPoints[0].dx, pathPoints[0].dy) + ..lineTo(pathPoints[1].dx, pathPoints[1].dy) + ..lineTo(pathPoints[2].dx, pathPoints[2].dy) + ..lineTo(pathPoints[3].dx, pathPoints[3].dy); + canvas.drawPath(path, paint); + + // Debug prints (optional, remove after testing) + // print('Snapped Source: $snappedSource'); + // print('Middle Point: ${pathPoints[1]}'); + // print('Snapped Target: $snappedTarget'); + // print('Angle: $angle'); + } + + @override + bool shouldRepaint(ArrowPainter oldDelegate) => + true; +} \ No newline at end of file diff --git a/lib/screens/dashflow/dashflow_builder/nodes.dart b/lib/screens/dashflow/dashflow_builder/nodes.dart new file mode 100644 index 000000000..9266125dd --- /dev/null +++ b/lib/screens/dashflow/dashflow_builder/nodes.dart @@ -0,0 +1,313 @@ +import 'package:apidash/providers/ui_providers.dart'; +import 'package:apidash/screens/dashflow/dashflow_builder/workflow_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class NodeData { + final int id; + final Offset offset; + final GlobalKey sizeKey = GlobalKey(); + final String title; // Added for settings + final String url; // Added for settings + final Map headers; // Added for settings + + + NodeData({required this.id, required this.offset, + GlobalKey? sizeKey,this.title = 'REST Call', + this.url = '', + this.headers = const {},}); + +NodeData copyWith({ + Offset? offset, + String? title, + String? url, + Map? headers, + }) { + return NodeData( + id: id, + offset: offset ?? this.offset, + title: title ?? this.title, + url: url ?? this.url, + headers: headers ?? this.headers, + ); + } +} + +class DraggableNode extends ConsumerStatefulWidget { + final NodeData node; + final Function(int id, Offset newOffset) onDrag; + final double gridSize; // Added for snap-to-grid + + const DraggableNode({ + super.key, + required this.node, + required this.onDrag, + required this.gridSize, + }); + + @override + ConsumerState createState() => _DraggableNodeState(); +} + +class _DraggableNodeState extends ConsumerState { + late Offset offset; + bool isDragging = false; + Offset? _dragStart; // Added for connection dragging + + @override + void initState() { + super.initState(); + offset = widget.node.offset; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onPanStart: (_) { + setState(() { + isDragging = true; + }); + }, + onPanUpdate: (details) { + if (isDragging) { + final zoom = (context + .findAncestorWidgetOfExactType() + ?.transformationController + ?.value + .getMaxScaleOnAxis() ?? + 1.0); + final adjustedDelta = details.delta * zoom; + offset += adjustedDelta; + // Snap to grid + final snappedOffset = Offset( + (offset.dx / widget.gridSize).round() * widget.gridSize, + (offset.dy / widget.gridSize).round() * widget.gridSize, + ); + widget.onDrag(widget.node.id, snappedOffset); + } + }, + onPanEnd: (_) { + setState(() { + isDragging = false; + }); + }, + onTap: () { + showDialog( + context: context, + builder: (_) => SettingsDialog( + node: widget.node, + onSave: (title, url, headers) { + ref.read(workflowProvider.notifier).updateNode( + widget.node.id, + title: title, + url: url, + headers: headers, + ); + }, + ), + ); + }, + onLongPressStart: (details) { + _dragStart = details.localPosition; + }, + onLongPressEnd: (details) { + final nodes = ref.read(workflowProvider); + final endNode = nodes.firstWhere( + (n) => + (n.offset - (offset + details.localPosition)).distance < 50 && + n.id != widget.node.id, + orElse: () => widget.node, + ); + if (endNode != widget.node) { + ref.read(connectionListProvider.notifier).state = [ + ...ref.read(connectionListProvider), + Connection(from: widget.node.id, to: endNode.id), + ]; + } + _dragStart = null; + }, + child: Card( + key: widget.node.sizeKey, + elevation: isDragging ? 16 : 4, + color: Colors.lightBlue[100], + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text("Node ${widget.node.id}, this is the test node", + style: Theme.of(context).textTheme.bodyMedium), + ), + ), + ); + } +} + +class ControlNode extends ConsumerStatefulWidget { + final Offset offset; + final Function(Offset newOffset) onDrag; + final double gridSize; + + const ControlNode({ + super.key, + required this.offset, + required this.onDrag, + required this.gridSize, + }); + + @override + ConsumerState createState() => _ControlNodeState(); +} + +class _ControlNodeState extends ConsumerState { + late Offset offset; + bool isDragging = false; + + void _runFlow(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Run Flow'), + content: const Text('Workflow executed successfully (mock).'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + void _addAnnotation(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Add Annotation'), + content: const TextField( + decoration: InputDecoration(hintText: 'Enter note')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Save'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + } + + void _clearCanvas(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Clear Canvas'), + content: const Text('This will reset all nodes. Proceed?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Clear'), + ), + ], + ), + ); + } + + @override + void initState() { + super.initState(); + offset = widget.offset; + } + + @override + Widget build(BuildContext context) { + return Positioned( + left: offset.dx, + top: offset.dy, + child: GestureDetector( + onPanUpdate: (details) { + setState(() { + offset += details.delta; + final snappedOffset = Offset( + (offset.dx / widget.gridSize).round() * widget.gridSize, + (offset.dy / widget.gridSize).round() * widget.gridSize, + ); + widget.onDrag(snappedOffset); + }); + }, + child: Card( + elevation: isDragging ? 8 : 4, + color: Colors.white, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 15.0, vertical: 5.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Icon(Icons.drag_indicator), + const SizedBox( + width: 10, + ), + IconButton( + onPressed: () { + ref.read(workflowProvider.notifier).addNode( + NodeData( + id: DateTime.now().millisecondsSinceEpoch, + offset: Offset( + (100 / widget.gridSize).round() * widget.gridSize, + (100 / widget.gridSize).round() * widget.gridSize, + ), + title: 'REST Call', + ), + ); + }, + icon: const Icon(Icons.add, size: 20), + tooltip: 'Add node', + ), + const SizedBox( + width: 10, + ), + ElevatedButton.icon( + label: const Text('Run'), + icon: const Icon(Icons.play_arrow, size: 20), + onPressed: () => _runFlow(context), + ), + const SizedBox( + width: 10, + ), + IconButton( + icon: const Icon(Icons.note_add, size: 20), + onPressed: () => _addAnnotation(context), + tooltip: 'Add Annotation', + ), + const SizedBox( + width: 10, + ), + IconButton( + icon: const Icon(Icons.delete, size: 20), + onPressed: () => _clearCanvas(context), + tooltip: 'Clear Canvas', + ), + ], + ), + ), + ), + ), + ); + } +} + +// Define Connection class for node connections +class Connection { + final int from; + final int to; + + Connection({required this.from, required this.to}); +} \ No newline at end of file diff --git a/lib/screens/dashflow/dashflow_builder/workflow_canvas.dart b/lib/screens/dashflow/dashflow_builder/workflow_canvas.dart new file mode 100644 index 000000000..49444378e --- /dev/null +++ b/lib/screens/dashflow/dashflow_builder/workflow_canvas.dart @@ -0,0 +1,154 @@ +import 'package:apidash/screens/dashflow/dashflow_builder/grid.dart'; +import 'package:apidash/screens/dashflow/dashflow_builder/node_connectors.dart'; +import 'package:apidash/screens/dashflow/dashflow_builder/nodes.dart'; +import 'package:flutter/material.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + + +class WorkflowCanvas extends ConsumerStatefulWidget { + const WorkflowCanvas({super.key}); + + @override + ConsumerState createState() => _WorkflowCanvasState(); +} + +class _WorkflowCanvasState extends ConsumerState { + final TransformationController _controller = TransformationController(); + bool _needsRepaint = false; + Offset _controlNodeOffset = const Offset(100, 100); // Initial position + + @override + void initState() { + super.initState(); + const canvasSize = 5000.0; + _controller.value = Matrix4.identity() + ..translate(-canvasSize / 2 + 100, -canvasSize / 2 + 100); + _controller.addListener(_onTransformChanged); + } + + @override + void dispose() { + _controller.removeListener(_onTransformChanged); + _controller.dispose(); + super.dispose(); + } + + void _onTransformChanged() { + if (!_needsRepaint) { + _needsRepaint = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _needsRepaint = false; + }); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context).size; + final canvasSize = 5000.00; + const double baseGridSize = 25; + final nodes = ref.watch(workflowProvider); + final hoveredNodeId = ref.watch(hoverNodeProvider); + final connections = ref.watch(connectionListProvider); + + return Scaffold( + appBar: AppBar(title: Text("Dashflow 1")), + body: Padding( + padding: const EdgeInsets.only(left: 5, right: 5, bottom: 10), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.all(Radius.circular(10)), + gradient: LinearGradient( + colors: [ + Colors.black12, + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.center, + ), + ), + child: Stack(children: [ + InteractiveViewer( + transformationController: _controller, + constrained: false, + boundaryMargin: EdgeInsets.all(canvasSize / 2), + minScale: 0.5, + maxScale: 3, + child: SizedBox( + height: canvasSize, + width: canvasSize, + child: Stack( + children: [ + CustomPaint( + painter: ArrowPainter( + nodes: nodes, + connections: connections, + canvasSize: canvasSize, + gridSize: baseGridSize, + hoveredNodeId: + hoveredNodeId, // Pass the hovered node ID + ), + ), + CustomPaint( + painter: GridPainter( + baseGridSize: baseGridSize, + canvasSize: canvasSize, + transformation: _controller.value, + viewportSize: mq, + ), + child: Stack( + clipBehavior: Clip.none, + children: nodes.map((node) { + // Translate node positions to center of canvas + final centeredOffset = Offset( + node.offset.dx + canvasSize / 2, + node.offset.dy + canvasSize / 2, + ); + return Positioned( + left: centeredOffset.dx, + top: centeredOffset.dy, + child: MouseRegion( + onEnter: (_) => ref + .read(hoverNodeProvider.notifier) + .state = node.id, + onExit: (_) => ref + .read(hoverNodeProvider.notifier) + .state = null, + child: DraggableNode( + gridSize: baseGridSize, + node: node, + onDrag: (id, offset) => ref + .read(workflowProvider.notifier) + .updateNodeOffset(id, offset), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ), + // Control node, always visible and draggable + ControlNode( + offset: _controlNodeOffset, + onDrag: (newOffset) { + setState(() { + _controlNodeOffset = newOffset; + }); + }, + gridSize: baseGridSize, + ), + ]), + ), + ), + ); + } +} diff --git a/lib/screens/dashflow/dashflow_builder/workflow_settings.dart b/lib/screens/dashflow/dashflow_builder/workflow_settings.dart new file mode 100644 index 000000000..669841287 --- /dev/null +++ b/lib/screens/dashflow/dashflow_builder/workflow_settings.dart @@ -0,0 +1,85 @@ +import 'package:apidash/screens/dashflow/dashflow_builder/nodes.dart'; +import 'package:flutter/material.dart'; + +class SettingsDialog extends StatefulWidget { + final NodeData node; + final Function(String title, String url, Map headers) onSave; + + const SettingsDialog({super.key, required this.node, required this.onSave}); + + @override + State createState() => _SettingsDialogState(); +} + +class _SettingsDialogState extends State { + late TextEditingController _titleController; + late TextEditingController _urlController; + late TextEditingController _headersController; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.node.title); + _urlController = TextEditingController(text: widget.node.url); + _headersController = TextEditingController( + text: widget.node.headers.entries.map((e) => '${e.key}: ${e.value}').join('\n'), + ); + } + + @override + void dispose() { + _titleController.dispose(); + _urlController.dispose(); + _headersController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Node Settings'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _urlController, + decoration: const InputDecoration(labelText: 'URL'), + ), + TextField( + controller: _headersController, + decoration: const InputDecoration(labelText: 'Headers (key: value)'), + maxLines: 4, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final headers = _headersController.text + .split('\n') + .fold>({}, (map, line) { + final parts = line.split(': '); + if (parts.length == 2) map[parts[0]] = parts[1]; + return map; + }); + widget.onSave( + _titleController.text, + _urlController.text, + headers, + ); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/dashflow/workflow_pane.dart b/lib/screens/dashflow/workflow_pane.dart new file mode 100644 index 000000000..22f0c062e --- /dev/null +++ b/lib/screens/dashflow/workflow_pane.dart @@ -0,0 +1,249 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/importer/import_dialog.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; +import '../common_widgets/common_widgets.dart'; + +class WorkflowPane extends ConsumerWidget { + const WorkflowPane({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final collection = ref.watch(collectionStateNotifierProvider); + var sm = ScaffoldMessenger.of(context); + if (collection == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + return Padding( + padding: (!context.isMediumWindow && kIsMacOS ? kPt24l4 : kPt8l4) + + (context.isMediumWindow ? kPb70 : EdgeInsets.zero), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SidebarHeader( + onAddNew: () { + ref.read(collectionStateNotifierProvider.notifier).add(); + }, + onImport: () { + importToCollectionPane(context, ref, sm); + }, + ), + if (context.isMediumWindow) kVSpacer6, + if (context.isMediumWindow) + Padding( + padding: kPh8, + child: EnvironmentDropdown(), + ), + kVSpacer10, + SidebarFilter( + filterHintText: "Filter by name or url", + onFilterFieldChanged: (value) { + ref.read(collectionSearchQueryProvider.notifier).state = + value.toLowerCase(); + }, + ), + kVSpacer10, + const Expanded( + child: RequestList(), + ), + kVSpacer5 + ], + ), + ); + } +} + +class RequestList extends ConsumerStatefulWidget { + const RequestList({ + super.key, + }); + + @override + ConsumerState createState() => _RequestListState(); +} + +class _RequestListState extends ConsumerState { + late final ScrollController controller; + + @override + void initState() { + super.initState(); + controller = ScrollController(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final requestSequence = ref.watch(requestSequenceProvider); + final requestItems = ref.watch(collectionStateNotifierProvider)!; + final alwaysShowCollectionPaneScrollbar = ref.watch(settingsProvider + .select((value) => value.alwaysShowCollectionPaneScrollbar)); + final filterQuery = ref.watch(collectionSearchQueryProvider).trim(); + + return Scrollbar( + controller: controller, + thumbVisibility: alwaysShowCollectionPaneScrollbar ? true : null, + radius: const Radius.circular(12), + child: filterQuery.isEmpty + ? ReorderableListView.builder( + padding: context.isMediumWindow + ? EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + right: 8, + ) + : kPe8, + scrollController: controller, + buildDefaultDragHandles: false, + itemCount: requestSequence.length, + onReorder: (int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + if (oldIndex != newIndex) { + ref + .read(collectionStateNotifierProvider.notifier) + .reorder(oldIndex, newIndex); + } + }, + itemBuilder: (context, index) { + var id = requestSequence[index]; + if (kIsMobile) { + return ReorderableDelayedDragStartListener( + key: ValueKey(id), + index: index, + child: Padding( + padding: kP1, + child: RequestItem( + id: id, + requestModel: requestItems[id]!, + ), + ), + ); + } + return ReorderableDragStartListener( + key: ValueKey(id), + index: index, + child: Padding( + padding: kP1, + child: RequestItem( + id: id, + requestModel: requestItems[id]!, + ), + ), + ); + }, + ) + : ListView( + padding: context.isMediumWindow + ? EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + right: 8, + ) + : kPe8, + controller: controller, + children: requestSequence.map((id) { + var item = requestItems[id]!; + if (item.httpRequestModel!.url + .toLowerCase() + .contains(filterQuery) || + item.name.toLowerCase().contains(filterQuery)) { + return Padding( + padding: kP1, + child: RequestItem( + id: id, + requestModel: item, + ), + ); + } + return kSizedBoxEmpty; + }).toList(), + ), + ); + } +} + +class RequestItem extends ConsumerWidget { + const RequestItem({ + super.key, + required this.id, + required this.requestModel, + }); + + final String id; + final RequestModel requestModel; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedIdStateProvider); + final editRequestId = ref.watch(selectedIdEditStateProvider); + + return SidebarRequestCard( + id: id, + apiType: requestModel.apiType, + method: requestModel.httpRequestModel!.method, + name: requestModel.name, + url: requestModel.httpRequestModel?.url, + selectedId: selectedId, + editRequestId: editRequestId, + onTap: () { + ref.read(selectedIdStateProvider.notifier).state = id; + kHomeScaffoldKey.currentState?.closeDrawer(); + }, + onSecondaryTap: () { + ref.read(selectedIdStateProvider.notifier).state = id; + }, + // onDoubleTap: () { + // ref.read(selectedIdStateProvider.notifier).state = id; + // ref.read(selectedIdEditStateProvider.notifier).state = id; + // }, + // controller: ref.watch(nameTextFieldControllerProvider), + focusNode: ref.watch(nameTextFieldFocusNodeProvider), + onChangedNameEditor: (value) { + value = value.trim(); + ref + .read(collectionStateNotifierProvider.notifier) + .update(id: editRequestId!, name: value); + }, + onTapOutsideNameEditor: () { + ref.read(selectedIdEditStateProvider.notifier).state = null; + }, + onMenuSelected: (ItemMenuOption item) { + if (item == ItemMenuOption.edit) { + // var controller = + // ref.read(nameTextFieldControllerProvider.notifier).state; + // controller.text = requestModel.name; + // controller.selection = TextSelection.fromPosition( + // TextPosition(offset: controller.text.length), + // ); + ref.read(selectedIdEditStateProvider.notifier).state = id; + Future.delayed( + const Duration(milliseconds: 150), + () => ref + .read(nameTextFieldFocusNodeProvider.notifier) + .state + .requestFocus(), + ); + } + if (item == ItemMenuOption.delete) { + ref.read(collectionStateNotifierProvider.notifier).remove(id: id); + } + if (item == ItemMenuOption.duplicate) { + ref.read(collectionStateNotifierProvider.notifier).duplicate(id: id); + } + }, + ); + } +} diff --git a/lib/screens/monitor/monitor.dart b/lib/screens/monitor/monitor.dart new file mode 100644 index 000000000..8875715cb --- /dev/null +++ b/lib/screens/monitor/monitor.dart @@ -0,0 +1,26 @@ +import 'package:apidash/screens/home_page/collection_pane.dart'; +import 'package:apidash/screens/home_page/editor_pane/editor_pane.dart'; +import 'package:apidash/screens/mobile/requests_page/requests_page.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:apidash/widgets/widgets.dart'; + +class MonitorPage extends StatelessWidget { + const MonitorPage({super.key}); + + @override + Widget build(BuildContext context) { + return context.isMediumWindow + ? const RequestResponsePage() + : const Column( + children: [ + Expanded( + child: DashboardSplitView( + sidebarWidget: CollectionPane(), + mainWidget: RequestEditorPane(), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/runner/runner.dart b/lib/screens/runner/runner.dart new file mode 100644 index 000000000..4bcfd990a --- /dev/null +++ b/lib/screens/runner/runner.dart @@ -0,0 +1,26 @@ +import 'package:apidash/screens/home_page/collection_pane.dart'; +import 'package:apidash/screens/home_page/editor_pane/editor_pane.dart'; +import 'package:apidash/screens/mobile/requests_page/requests_page.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:apidash/widgets/widgets.dart'; + +class RunnerPage extends StatelessWidget { + const RunnerPage({super.key}); + + @override + Widget build(BuildContext context) { + return context.isMediumWindow + ? const RequestResponsePage() + : const Column( + children: [ + Expanded( + child: DashboardSplitView( + sidebarWidget: CollectionPane(), + mainWidget: RequestEditorPane(), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/packages/apidash_core/pubspec_overrides.yaml b/packages/apidash_core/pubspec_overrides.yaml index 61081fc76..9ed8c31bf 100644 --- a/packages/apidash_core/pubspec_overrides.yaml +++ b/packages/apidash_core/pubspec_overrides.yaml @@ -1,10 +1,10 @@ # melos_managed_dependency_overrides: curl_parser,insomnia_collection,postman,seed dependency_overrides: curl_parser: - path: ../curl_parser + path: ..\\curl_parser insomnia_collection: - path: ../insomnia_collection + path: ..\\insomnia_collection postman: - path: ../postman + path: ..\\postman seed: - path: ../seed + path: ..\\seed diff --git a/pubspec.lock b/pubspec.lock index 9881558db..4540793ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1741,7 +1741,7 @@ packages: source: hosted version: "1.1.16" vector_math: - dependency: transitive + dependency: "direct main" description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" diff --git a/pubspec.yaml b/pubspec.yaml index 28ffae063..9ab7716dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: url: https://github.com/google/flutter-desktop-embedding.git path: plugins/window_size carousel_slider: ^5.0.0 + vector_math: ^2.1.4 dependency_overrides: extended_text_field: ^16.0.0 @@ -95,3 +96,4 @@ flutter: uses-material-design: true assets: - assets/ + - assets/images/