diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 000000000..e27efe261 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,40 @@ +name: Release API Templates + +on: + push: + paths: + - 'lib/screens/explorer/api_templates/**' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Zip API Templates + run: zip -r api_templates.zip lib/screens/explorer/api_templates + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: templates-${{ github.sha }} + release_name: API Templates Release ${{ github.sha }} + draft: false + prerelease: false + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./api_templates.zip + asset_name: api_templates.zip + asset_content_type: application/zip diff --git a/lib/models/explorer_model.dart b/lib/models/explorer_model.dart new file mode 100644 index 000000000..c3ae6cceb --- /dev/null +++ b/lib/models/explorer_model.dart @@ -0,0 +1,59 @@ +import 'models.dart'; + +class ApiTemplate { + final Info info; + final List requests; + + ApiTemplate({required this.info, required this.requests}); + + /// Parses JSON data into an ApiTemplate object. + factory ApiTemplate.fromJson(Map json) { + return ApiTemplate( + info: Info.fromJson(json['info'] ?? {}), + requests: (json['requests'] as List?) + ?.map((request) => RequestModel.fromJson(request)) + .toList() ?? + [], + ); + } + + /// Converts the ApiTemplate back to JSON. + Map toJson() { + return { + 'info': info.toJson(), + 'requests': requests.map((request) => request.toJson()).toList(), + }; + } +} + +/// Represents metadata (e.g., title, description, tags). +class Info { + final String title; + final String description; + final List tags; + + Info({ + required this.title, + required this.description, + required this.tags, + }); + + /// Parses JSON data into an Info object. + /// Future extensions: Add fields like category, version, or lastUpdated. + factory Info.fromJson(Map json) { + return Info( + title: json['title'] ?? 'Untitled', + description: json['description'] ?? 'No description', + tags: List.from(json['tags'] ?? []), + ); + } + + /// Converts the Info object back to JSON. + Map toJson() { + return { + 'title': title, + 'description': description, + 'tags': tags, + }; + } +} diff --git a/lib/models/models.dart b/lib/models/models.dart index 5d17479a0..d6be820c1 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -2,3 +2,4 @@ export 'history_meta_model.dart'; export 'history_request_model.dart'; export 'request_model.dart'; export 'settings_model.dart'; +export 'explorer_model.dart'; diff --git a/lib/providers/templates_provider.dart b/lib/providers/templates_provider.dart new file mode 100644 index 000000000..a2d499729 --- /dev/null +++ b/lib/providers/templates_provider.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/services/templates_service.dart'; + +class TemplatesState { + final List templates; + final bool isLoading; + final String? error; + final bool isCached; + + TemplatesState({ + this.templates = const [], + this.isLoading = false, + this.error, + this.isCached = false, + }); + + TemplatesState copyWith({ + List? templates, + bool? isLoading, + String? error, + bool? isCached, + }) { + return TemplatesState( + templates: templates ?? this.templates, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + isCached: isCached ?? this.isCached, + ); + } +} + +class TemplatesNotifier extends StateNotifier { + TemplatesNotifier() : super(TemplatesState()) { + loadInitialTemplates(); + } + + Future loadInitialTemplates() async { + state = state.copyWith(isLoading: true, error: null); + try { + final templates = await TemplatesService.loadTemplates(); + final isCached = await TemplatesService.hasCachedTemplates(); + state = state.copyWith( + templates: templates, + isLoading: false, + isCached: isCached, + ); + } catch (e) { + state = state.copyWith( + templates: [], + isLoading: false, + error: 'Failed to load templates: $e', + ); + } + } + + Future fetchTemplatesFromGitHub() async { + state = state.copyWith(isLoading: true, error: null); + try { + final templates = await TemplatesService.fetchTemplatesFromGitHub(); + state = state.copyWith( + templates: templates, + isLoading: false, + isCached: true, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: 'Failed to fetch templates: $e', + ); + } + } +} + +final templatesProvider = StateNotifierProvider( + (ref) => TemplatesNotifier(), +); \ No newline at end of file diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 428ffaebc..2127b53ef 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -9,6 +9,7 @@ import 'common_widgets/common_widgets.dart'; import 'envvar/environment_page.dart'; import 'home_page/home_page.dart'; import 'history/history_page.dart'; +import 'explorer/explorer_page.dart'; import 'settings_page.dart'; class Dashboard extends ConsumerWidget { @@ -68,6 +69,19 @@ 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.explore_outlined), + selectedIcon: const Icon(Icons.explore), + ), + Text( + 'Explorer', + style: Theme.of(context).textTheme.labelSmall, + ), ], ), Expanded( @@ -92,7 +106,7 @@ class Dashboard extends ConsumerWidget { padding: const EdgeInsets.only(bottom: 16.0), child: NavbarButton( railIdx: railIdx, - buttonIdx: 3, + buttonIdx: 4, selectedIcon: Icons.settings, icon: Icons.settings_outlined, label: 'Settings', @@ -118,7 +132,8 @@ class Dashboard extends ConsumerWidget { HomePage(), EnvironmentPage(), HistoryPage(), - SettingsPage(), + ExplorerPage(), // Added ExplorerPage at index 3 + SettingsPage(), // Shifted to index 4 ], ), ) diff --git a/lib/screens/explorer/api_templates/mock/blog_post.json b/lib/screens/explorer/api_templates/mock/blog_post.json new file mode 100644 index 000000000..6cbb823c0 --- /dev/null +++ b/lib/screens/explorer/api_templates/mock/blog_post.json @@ -0,0 +1,113 @@ +{ + "info": { + "title": "Blog Post API", + "description": "API for managing blog posts", + "tags": ["blog", "posts"] + }, + "requests": [ + { + "id": "post_create", + "apiType": "rest", + "name": "Create post", + "description": "Create a new blog post", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/posts", + "headers": [ + {"name": "Content-Type", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\"title\":\"New Post\",\"content\":\"Content here\"}", + "query": null, + "formData": null + }, + "responseStatus": 201, + "message": "Post created", + "httpResponseModel": { + "statusCode": 201, + "headers": { + "Content-Type": "application/json", + "Content-Length": "89" + }, + "requestHeaders": { + "Content-Type": "application/json" + }, + "body": "{\"id\":1,\"title\":\"New Post\",\"content\":\"Content here\",\"created_at\":\"2025-04-25T10:30:00Z\"}", + "formattedBody": "{\n \"id\": 1,\n \"title\": \"New Post\",\n \"content\": \"Content here\",\n \"created_at\": \"2025-04-25T10:30:00Z\"\n}", + "time": 240000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "post_get_all", + "apiType": "rest", + "name": "List posts", + "description": "Get all blog posts", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/posts", + "headers": [], + "params": [ + {"name": "limit", "value": "10"} + ], + "isHeaderEnabledList": [], + "isParamEnabledList": [true], + "bodyContentType": "json", + "body": null, + "query": "limit=10", + "formData": null + }, + "responseStatus": 200, + "message": "Success", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "126" + }, + "requestHeaders": {}, + "body": "{\"data\":[{\"id\":1,\"title\":\"New Post\"},{\"id\":2,\"title\":\"Another Post\"}],\"meta\":{\"total\":2}}", + "formattedBody": "{\n \"data\": [\n {\n \"id\": 1,\n \"title\": \"New Post\"\n },\n {\n \"id\": 2,\n \"title\": \"Another Post\"\n }\n ],\n \"meta\": {\n \"total\": 2\n }\n}", + "time": 150000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "post_delete", + "apiType": "rest", + "name": "Delete post", + "description": "Delete a post by ID", + "httpRequestModel": { + "method": "delete", + "url": "https://api.example.com/v1/posts/1", + "headers": [], + "params": [], + "isHeaderEnabledList": [], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": 204, + "message": "Deleted", + "httpResponseModel": { + "statusCode": 204, + "headers": { + "Content-Length": "0" + }, + "requestHeaders": {}, + "body": "", + "formattedBody": "", + "time": 110000 + }, + "isWorking": false, + "sendingTime": null + } + ] +} \ No newline at end of file diff --git a/lib/screens/explorer/api_templates/mock/ecommerce.json b/lib/screens/explorer/api_templates/mock/ecommerce.json new file mode 100644 index 000000000..35a51f8bd --- /dev/null +++ b/lib/screens/explorer/api_templates/mock/ecommerce.json @@ -0,0 +1,130 @@ +{ + "info": { + "title": "E-commerce API", + "description": "API for managing products, orders, and customers in an e-commerce platform", + "tags": ["ecommerce", "products", "orders", "customers"] + }, + "requests": [ + { + "id": "order_get", + "apiType": "rest", + "name": "Get Order", + "description": "Retrieves details about a specific order", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/orders/ord_456", + "headers": [ + {"name": "Accept", "value": "application/json"}, + {"name": "Authorization", "value": "Bearer YOUR_TOKEN"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": 200, + "message": "Order retrieved successfully", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "612" + }, + "requestHeaders": { + "Accept": "application/json", + "Authorization": "Bearer YOUR_TOKEN" + }, + "body": "{\n \"id\": \"ord_456\",\n \"customer_id\": \"cust_789\",\n \"status\": \"shipped\",\n \"items\": [\n {\n \"product_id\": \"prod_123\",\n \"name\": \"Wireless Earbuds\",\n \"quantity\": 2,\n \"unit_price\": 79.99,\n \"subtotal\": 159.98\n },\n {\n \"product_id\": \"prod_124\",\n \"name\": \"Smart Watch\",\n \"quantity\": 1,\n \"unit_price\": 129.99,\n \"subtotal\": 129.99\n }\n ],\n \"subtotal\": 289.97,\n \"tax\": 23.20,\n \"shipping\": 12.99,\n \"total\": 326.16,\n \"shipping_address\": {\n \"street\": \"123 Main St\",\n \"city\": \"Anytown\",\n \"state\": \"CA\",\n \"postal_code\": \"12345\",\n \"country\": \"US\"\n },\n \"tracking_number\": \"1ZW5Y9949045539359\",\n \"created_at\": \"2025-04-25T16:30:15Z\",\n \"updated_at\": \"2025-04-25T18:45:22Z\"\n}", + "formattedBody": "{\n \"id\": \"ord_456\",\n \"customer_id\": \"cust_789\",\n \"status\": \"shipped\",\n \"items\": [\n {\n \"product_id\": \"prod_123\",\n \"name\": \"Wireless Earbuds\",\n \"quantity\": 2,\n \"unit_price\": 79.99,\n \"subtotal\": 159.98\n },\n {\n \"product_id\": \"prod_124\",\n \"name\": \"Smart Watch\",\n \"quantity\": 1,\n \"unit_price\": 129.99,\n \"subtotal\": 129.99\n }\n ],\n \"subtotal\": 289.97,\n \"tax\": 23.20,\n \"shipping\": 12.99,\n \"total\": 326.16,\n \"shipping_address\": {\n \"street\": \"123 Main St\",\n \"city\": \"Anytown\",\n \"state\": \"CA\",\n \"postal_code\": \"12345\",\n \"country\": \"US\"\n },\n \"tracking_number\": \"1ZW5Y9949045539359\",\n \"created_at\": \"2025-04-25T16:30:15Z\",\n \"updated_at\": \"2025-04-25T18:45:22Z\"\n}", + "bodyBytes": null, + "time": 143000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "order_update_status", + "apiType": "rest", + "name": "Update Order Status", + "description": "Updates the status of an existing order", + "httpRequestModel": { + "method": "patch", + "url": "https://api.example.com/v1/orders/ordi_456/status", + "headers": [ + {"name": "Content-Type", "value": "application/json"}, + {"name": "Authorization", "value": "Bearer YOUR_TOKEN"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\n \"status\": \"delivered\",\n \"notes\": \"Delivered to customer's porch\"\n}", + "query": null, + "formData": null + }, + "responseStatus": 200, + "message": "Order status updated successfully", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "128" + }, + "requestHeaders": { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_TOKEN" + }, + "body": "{\n \"id\": \"ord_456\",\n \"status\": \"delivered\",\n \"updated_at\": \"2025-04-26T10:15:30Z\",\n \"notes\": \"Delivered to customer's porch\"\n}", + "formattedBody": "{\n \"id\": \"ord_456\",\n \"status\": \"delivered\",\n \"updated_at\": \"2025-04-26T10:15:30Z\",\n \"notes\": \"Delivered to customer's porch\"\n}", + "bodyBytes": null, + "time": 185000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "customer_create", + "apiType": "rest", + "name": "Create Customer", + "description": "Registers a new customer in the system", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/customers", + "headers": [ + {"name": "Content-Type", "value": "application/json"}, + {"name": "Authorization", "value": "Bearer YOUR_TOKEN"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\n \"email\": \"jane.smith@example.com\",\n \"first_name\": \"Jane\",\n \"last_name\": \"Smith\",\n \"phone\": \"+1-555-123-4567\",\n \"addresses\": [\n {\n \"type\": \"shipping\",\n \"street\": \"123 Main St\",\n \"city\": \"Anytown\",\n \"state\": \"CA\",\n \"postal_code\": \"12345\",\n \"country\": \"US\"\n }\n ]\n}", + "query": null, + "formData": null + }, + "responseStatus": 201, + "message": "Customer created successfully", + "httpResponseModel": { + "statusCode": 201, + "headers": { + "Content-Type": "application/json", + "Content-Length": "294", + "Location": "https://api.example.com/v1/customers/cust_789" + }, + "requestHeaders": { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_TOKEN" + }, + "body": "{\n \"id\": \"cust_789\",\n \"email\": \"jane.smith@example.com\",\n \"first_name\": \"Jane\",\n \"last_name\": \"Smith\",\n \"phone\": \"+1-555-123-4567\",\n \"addresses\": [\n {\n \"type\": \"shipping\",\n \"street\": \"123 Main St\",\n \"city\": \"Anytown\",\n \"state\": \"CA\",\n \"postal_code\": \"12345\",\n \"country\": \"US\"\n }\n ],\n \"created_at\": \"2025-04-25T11:20:35Z\"\n}", + "formattedBody": "{\n \"id\": \"cust_789\",\n \"email\": \"jane.smith@example.com\",\n \"first_name\": \"Jane\",\n \"last_name\": \"Smith\",\n \"phone\": \"+1-555-123-4567\",\n \"addresses\": [\n {\n \"type\": \"shipping\",\n \"street\": \"123 Main St\",\n \"city\": \"Anytown\",\n \"state\": \"CA\",\n \"postal_code\": \"12345\",\n \"country\": \"US\"\n }\n ],\n \"created_at\": \"2025-04-25T11:20:35Z\"\n}", + "bodyBytes": null, + "time": 289000 + }, + "isWorking": false, + "sendingTime": null + } + ] +} \ No newline at end of file diff --git a/lib/screens/explorer/api_templates/mock/order_api.json b/lib/screens/explorer/api_templates/mock/order_api.json new file mode 100644 index 000000000..c43c06482 --- /dev/null +++ b/lib/screens/explorer/api_templates/mock/order_api.json @@ -0,0 +1,59 @@ +{ + "info": { + "title": "Order Processing API", + "description": "API for processing customer orders and checking order status", + "tags": ["orders", "e-commerce", "processing"] + }, + "requests": [ + { + "id": "order_create", + "apiType": "rest", + "name": "Create an order", + "description": "Places a new customer order", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/orders", + "headers": [ + {"name": "Content-Type", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\"customerId\": \"cust123\", \"items\": [{\"productId\": \"101\", \"quantity\": 2}]}", + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + }, + { + "id": "order_status", + "apiType": "rest", + "name": "Get order status", + "description": "Fetches the status of a specific order", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/orders/1001", + "headers": [], + "params": [ + {"name": "id", "value": "1001"} + ], + "isHeaderEnabledList": [], + "isParamEnabledList": [true], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + } + ] + } \ No newline at end of file diff --git a/lib/screens/explorer/api_templates/mock/pet_api.json b/lib/screens/explorer/api_templates/mock/pet_api.json new file mode 100644 index 000000000..e39e19807 --- /dev/null +++ b/lib/screens/explorer/api_templates/mock/pet_api.json @@ -0,0 +1,162 @@ +{ + "info": { + "title": "Swagger Petstore", + "description": "API for managing pets in the pet store", + "tags": ["pets", "petstore", "api"] + }, + "requests": [ + { + "id": "list_pets", + "apiType": "rest", + "name": "List all pets", + "description": "Returns a paged array of pets", + "httpRequestModel": { + "method": "get", + "url": "http://petstore.swagger.io/v1/pets", + "headers": [ + {"name": "Accept", "value": "application/json"} + ], + "params": [ + {"name": "limit", "value": "10"} + ], + "isHeaderEnabledList": [true], + "isParamEnabledList": [true], + "bodyContentType": "json", + "body": null, + "query": "limit=10", + "formData": null + }, + "responseStatus": 200, + "message": "An paged array of pets", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "x-next": "/v1/pets?page=2", + "Content-Length": "157" + }, + "requestHeaders": { + "Accept": "application/json" + }, + "body": "[{\"id\":1,\"name\":\"Dog\",\"tag\":\"golden\"},{\"id\":2,\"name\":\"Cat\",\"tag\":\"siamese\"}]", + "formattedBody": "[\n {\n \"id\": 1,\n \"name\": \"Dog\",\n \"tag\": \"golden\"\n },\n {\n \"id\": 2,\n \"name\": \"Cat\",\n \"tag\": \"siamese\"\n }\n]", + "time": 120000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "create_pet", + "apiType": "rest", + "name": "Create a pet", + "description": "Creates a new pet in the store", + "httpRequestModel": { + "method": "post", + "url": "http://petstore.swagger.io/v1/pets", + "headers": [ + {"name": "Content-Type", "value": "application/json"}, + {"name": "Accept", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\"name\":\"Fluffy\",\"tag\":\"poodle\"}", + "query": null, + "formData": null + }, + "responseStatus": 201, + "message": "Pet created successfully", + "httpResponseModel": { + "statusCode": 201, + "headers": { + "Content-Type": "application/json", + "Content-Length": "0" + }, + "requestHeaders": { + "Content-Type": "application/json", + "Accept": "application/json" + }, + "body": "", + "formattedBody": "", + "time": 150000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "get_pet_by_id", + "apiType": "rest", + "name": "Info for a specific pet", + "description": "Returns details about a specific pet by ID", + "httpRequestModel": { + "method": "get", + "url": "http://petstore.swagger.io/v1/pets/1", + "headers": [ + {"name": "Accept", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": 200, + "message": "Expected response to a valid request", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "45" + }, + "requestHeaders": { + "Accept": "application/json" + }, + "body": "{\"id\":1,\"name\":\"Dog\",\"tag\":\"golden\"}", + "formattedBody": "{\n \"id\": 1,\n \"name\": \"Dog\",\n \"tag\": \"golden\"\n}", + "time": 130000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "error_response", + "apiType": "rest", + "name": "Error example", + "description": "Example of an error response", + "httpRequestModel": { + "method": "get", + "url": "http://petstore.swagger.io/v1/pets/999", + "headers": [ + {"name": "Accept", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": 404, + "message": "Pet not found", + "httpResponseModel": { + "statusCode": 404, + "headers": { + "Content-Type": "application/json", + "Content-Length": "49" + }, + "requestHeaders": { + "Accept": "application/json" + }, + "body": "{\"code\":404,\"message\":\"Pet with ID 999 not found\"}", + "formattedBody": "{\n \"code\": 404,\n \"message\": \"Pet with ID 999 not found\"\n}", + "time": 110000 + }, + "isWorking": false, + "sendingTime": null + } + ] + } \ No newline at end of file diff --git a/lib/screens/explorer/api_templates/mock/product.json b/lib/screens/explorer/api_templates/mock/product.json new file mode 100644 index 000000000..4b6a2b169 --- /dev/null +++ b/lib/screens/explorer/api_templates/mock/product.json @@ -0,0 +1,84 @@ +{ + "info": { + "title": "Product Catalog API", + "description": "API for managing a product catalog, including listing and adding products", + "tags": ["products", "catalog", "e-commerce"] + }, + "requests": [ + { + "id": "product_list", + "apiType": "rest", + "name": "List all products", + "description": "Retrieves a list of all products in the catalog", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/products", + "headers": [], + "params": [ + {"name": "category", "value": "electronics"} + ], + "isHeaderEnabledList": [], + "isParamEnabledList": [true], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + }, + { + "id": "product_get", + "apiType": "rest", + "name": "Get product details", + "description": "Fetches details of a specific product by ID", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/products/101", + "headers": [], + "params": [ + {"name": "id", "value": "101"} + ], + "isHeaderEnabledList": [], + "isParamEnabledList": [true], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + }, + { + "id": "product_add", + "apiType": "rest", + "name": "Add a new product", + "description": "Adds a new product to the catalog", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/products", + "headers": [ + {"name": "Content-Type", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\"name\": \"Smartphone\", \"price\": 599.99, \"category\": \"electronics\"}", + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + } + ] + } \ No newline at end of file diff --git a/lib/screens/explorer/api_templates/mock/user_management_api.json b/lib/screens/explorer/api_templates/mock/user_management_api.json new file mode 100644 index 000000000..b4a7265b7 --- /dev/null +++ b/lib/screens/explorer/api_templates/mock/user_management_api.json @@ -0,0 +1,84 @@ +{ + "info": { + "title": "User Management API", + "description": "API for managing user accounts, including registration, retrieval, and updates", + "tags": ["users", "management", "authentication"] + }, + "requests": [ + { + "id": "user_register", + "apiType": "rest", + "name": "Register a new user", + "description": "Creates a new user account", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/users", + "headers": [ + {"name": "Content-Type", "value": "application/json"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\"username\": \"johndoe\", \"email\": \"john@example.com\", \"password\": \"secure123\"}", + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + }, + { + "id": "user_get_all", + "apiType": "rest", + "name": "Get all users", + "description": "Fetches a list of all users", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/users", + "headers": [], + "params": [], + "isHeaderEnabledList": [], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + }, + { + "id": "user_update", + "apiType": "rest", + "name": "Update user details", + "description": "Updates details of a specific user by ID", + "httpRequestModel": { + "method": "put", + "url": "https://api.example.com/v1/users/1", + "headers": [ + {"name": "Content-Type", "value": "application/json"} + ], + "params": [ + {"name": "id", "value": "1"} + ], + "isHeaderEnabledList": [true], + "isParamEnabledList": [true], + "bodyContentType": "json", + "body": "{\"email\": \"john.doe@example.com\"}", + "query": null, + "formData": null + }, + "responseStatus": null, + "message": null, + "httpResponseModel": null, + "isWorking": false, + "sendingTime": null + } + ] + } \ No newline at end of file diff --git a/lib/screens/explorer/api_templates/webhook_api.json b/lib/screens/explorer/api_templates/webhook_api.json new file mode 100644 index 000000000..140305382 --- /dev/null +++ b/lib/screens/explorer/api_templates/webhook_api.json @@ -0,0 +1,205 @@ +{ + "info": { + "title": "Webhook Management API", + "description": "API for creating, configuring, and managing webhooks for event notifications", + "tags": ["webhooks", "events", "integrations", "notifications"] + }, + "requests": [ + { + "id": "webhook_register", + "apiType": "rest", + "name": "Register Webhook", + "description": "Register a new webhook endpoint to receive event notifications", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/webhooks", + "headers": [ + {"name": "Content-Type", "value": "application/json"}, + {"name": "Authorization", "value": "Bearer YOUR_API_KEY"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\"],\n \"description\": \"Payment notifications endpoint\",\n \"active\": true,\n \"secret\": \"whsec_8dj29dj29d8j29dj\"\n}", + "query": null, + "formData": null + }, + "responseStatus": 201, + "message": "Webhook registered successfully", + "httpResponseModel": { + "statusCode": 201, + "headers": { + "Content-Type": "application/json", + "Content-Length": "315", + "Location": "https://api.example.com/v1/webhooks/wh_92jd92j9d2j9" + }, + "requestHeaders": { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_API_KEY" + }, + "body": "{\n \"id\": \"wh_92jd92j9d2j9\",\n \"object\": \"webhook\",\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\"],\n \"description\": \"Payment notifications endpoint\",\n \"active\": true,\n \"created_at\": \"2025-04-29T10:05:32Z\"\n}", + "formattedBody": "{\n \"id\": \"wh_92jd92j9d2j9\",\n \"object\": \"webhook\",\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\"],\n \"description\": \"Payment notifications endpoint\",\n \"active\": true,\n \"created_at\": \"2025-04-29T10:05:32Z\"\n}", + "bodyBytes": null, + "time": 232000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "webhook_list", + "apiType": "rest", + "name": "List Webhooks", + "description": "Get all registered webhooks", + "httpRequestModel": { + "method": "get", + "url": "https://api.example.com/v1/webhooks", + "headers": [ + {"name": "Authorization", "value": "Bearer YOUR_API_KEY"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": 200, + "message": "Webhooks retrieved successfully", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "562" + }, + "requestHeaders": { + "Authorization": "Bearer YOUR_API_KEY" + }, + "body": "{\n \"object\": \"list\",\n \"data\": [\n {\n \"id\": \"wh_92jd92j9d2j9\",\n \"object\": \"webhook\",\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\"],\n \"description\": \"Payment notifications endpoint\",\n \"active\": true,\n \"created_at\": \"2025-04-29T10:05:32Z\"\n },\n {\n \"id\": \"wh_8jf83jf8jf38\",\n \"object\": \"webhook\",\n \"url\": \"https://your-app.example.org/hooks/refunds\",\n \"events\": [\"refund.created\", \"refund.updated\"],\n \"description\": \"Refund notifications\",\n \"active\": true,\n \"created_at\": \"2025-04-20T14:22:18Z\"\n }\n ],\n \"has_more\": false,\n \"total_count\": 2\n}", + "formattedBody": "{\n \"object\": \"list\",\n \"data\": [\n {\n \"id\": \"wh_92jd92j9d2j9\",\n \"object\": \"webhook\",\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\"],\n \"description\": \"Payment notifications endpoint\",\n \"active\": true,\n \"created_at\": \"2025-04-29T10:05:32Z\"\n },\n {\n \"id\": \"wh_8jf83jf8jf38\",\n \"object\": \"webhook\",\n \"url\": \"https://your-app.example.org/hooks/refunds\",\n \"events\": [\"refund.created\", \"refund.updated\"],\n \"description\": \"Refund notifications\",\n \"active\": true,\n \"created_at\": \"2025-04-20T14:22:18Z\"\n }\n ],\n \"has_more\": false,\n \"total_count\": 2\n}", + "bodyBytes": null, + "time": 180000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "webhook_update", + "apiType": "rest", + "name": "Update Webhook", + "description": "Update an existing webhook configuration", + "httpRequestModel": { + "method": "patch", + "url": "https://api.example.com/v1/webhooks/wh_92jd92j9d2j9", + "headers": [ + {"name": "Content-Type", "value": "application/json"}, + {"name": "Authorization", "value": "Bearer YOUR_API_KEY"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\", \"payment.refunded\"],\n \"description\": \"Updated payment notifications endpoint\"\n}", + "query": null, + "formData": null + }, + "responseStatus": 200, + "message": "Webhook updated successfully", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "336" + }, + "requestHeaders": { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_API_KEY" + }, + "body": "{\n \"id\": \"wh_92jd92j9d2j9\",\n \"object\": \"webhook\",\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\", \"payment.refunded\"],\n \"description\": \"Updated payment notifications endpoint\",\n \"active\": true,\n \"created_at\": \"2025-04-29T10:05:32Z\",\n \"updated_at\": \"2025-04-29T10:20:15Z\"\n}", + "formattedBody": "{\n \"id\": \"wh_92jd92j9d2j9\",\n \"object\": \"webhook\",\n \"url\": \"https://your-server.example.com/webhook\",\n \"events\": [\"payment.created\", \"payment.succeeded\", \"payment.failed\", \"payment.refunded\"],\n \"description\": \"Updated payment notifications endpoint\",\n \"active\": true,\n \"created_at\": \"2025-04-29T10:05:32Z\",\n \"updated_at\": \"2025-04-29T10:20:15Z\"\n}", + "bodyBytes": null, + "time": 195000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "webhook_delete", + "apiType": "rest", + "name": "Delete Webhook", + "description": "Remove a webhook endpoint", + "httpRequestModel": { + "method": "delete", + "url": "https://api.example.com/v1/webhooks/wh_92jd92j9d2j9", + "headers": [ + {"name": "Authorization", "value": "Bearer YOUR_API_KEY"} + ], + "params": [], + "isHeaderEnabledList": [true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": null, + "query": null, + "formData": null + }, + "responseStatus": 204, + "message": "Webhook deleted successfully", + "httpResponseModel": { + "statusCode": 204, + "headers": { + "Content-Length": "0" + }, + "requestHeaders": { + "Authorization": "Bearer YOUR_API_KEY" + }, + "body": "", + "formattedBody": "", + "bodyBytes": null, + "time": 165000 + }, + "isWorking": false, + "sendingTime": null + }, + { + "id": "webhook_test", + "apiType": "rest", + "name": "Test Webhook", + "description": "Send a test event to a webhook endpoint", + "httpRequestModel": { + "method": "post", + "url": "https://api.example.com/v1/webhooks/wh_8jf83jf8jf38/test", + "headers": [ + {"name": "Content-Type", "value": "application/json"}, + {"name": "Authorization", "value": "Bearer YOUR_API_KEY"} + ], + "params": [], + "isHeaderEnabledList": [true, true], + "isParamEnabledList": [], + "bodyContentType": "json", + "body": "{\n \"event\": \"refund.created\"\n}", + "query": null, + "formData": null + }, + "responseStatus": 200, + "message": "Test event sent successfully", + "httpResponseModel": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Content-Length": "254" + }, + "requestHeaders": { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_API_KEY" + }, + "body": "{\n \"id\": \"evt_test_83j93jd93j\",\n \"object\": \"event\",\n \"type\": \"refund.created\",\n \"created\": \"2025-04-29T10:35:42Z\",\n \"data\": {\n \"object\": {\n \"id\": \"ref_test_8j382j38\",\n \"object\": \"refund\",\n \"status\": \"succeeded\"\n }\n },\n \"pending_webhooks\": 1,\n \"request\": \"req_test_92j92j29d\"\n}", + "formattedBody": "{\n \"id\": \"evt_test_83j93jd93j\",\n \"object\": \"event\",\n \"type\": \"refund.created\",\n \"created\": \"2025-04-29T10:35:42Z\",\n \"data\": {\n \"object\": {\n \"id\": \"ref_test_8j382j38\",\n \"object\": \"refund\",\n \"status\": \"succeeded\"\n }\n },\n \"pending_webhooks\": 1,\n \"request\": \"req_test_92j92j29d\"\n}", + "bodyBytes": null, + "time": 215000 + }, + "isWorking": false, + "sendingTime": null + } + ] +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/api_search_bar.dart b/lib/screens/explorer/common_widgets/api_search_bar.dart new file mode 100644 index 000000000..d39dfe3d8 --- /dev/null +++ b/lib/screens/explorer/common_widgets/api_search_bar.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +class ApiSearchBar extends StatefulWidget { + final String hintText; + final ValueChanged? onChanged; + final VoidCallback? onClear; + + const ApiSearchBar({ + super.key, + this.hintText = 'Search...', + this.onChanged, + this.onClear, + }); + + @override + ApiSearchBarState createState() => ApiSearchBarState(); +} + +class ApiSearchBarState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 36, // Smaller height + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(18), + right: Radius.circular(18), + ), + ), + child: TextField( + controller: _controller, + onChanged: widget.onChanged, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: const TextStyle(fontSize: 14), + prefixIcon: const Icon(Icons.search, size: 18), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + _controller.clear(); + widget.onChanged?.call(''); + widget.onClear?.call(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(18), + right: Radius.circular(18), + ), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/card_description.dart b/lib/screens/explorer/common_widgets/card_description.dart new file mode 100644 index 000000000..dac47d9c8 --- /dev/null +++ b/lib/screens/explorer/common_widgets/card_description.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class CardDescription extends StatelessWidget { + final String description; + final int maxLines; + + const CardDescription({ + Key? key, + required this.description, + this.maxLines = 5, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: double.infinity, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + description.isEmpty ? 'No description' : description, + textAlign: TextAlign.left, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/card_title.dart b/lib/screens/explorer/common_widgets/card_title.dart new file mode 100644 index 000000000..7188daea9 --- /dev/null +++ b/lib/screens/explorer/common_widgets/card_title.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class CardTitle extends StatelessWidget { + final String title; + final IconData icon; + final Color? iconColor; + + const CardTitle({ + Key? key, + required this.title, + required this.icon, + this.iconColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: (iconColor ?? theme.colorScheme.primary).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 18, + color: iconColor ?? theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/chip.dart b/lib/screens/explorer/common_widgets/chip.dart new file mode 100644 index 000000000..5cde97a95 --- /dev/null +++ b/lib/screens/explorer/common_widgets/chip.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import "package:apidash/consts.dart"; + +class CustomChip extends StatelessWidget { + final String label; + final Color? backgroundColor; + final Color? textColor; + final Color? borderColor; + final double fontSize; + final FontWeight fontWeight; + final EdgeInsets padding; + final VisualDensity visualDensity; + + const CustomChip({ + super.key, + required this.label, + this.backgroundColor, + this.textColor, + this.borderColor, + this.fontSize = 12, + this.fontWeight = FontWeight.bold, + this.padding = const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + this.visualDensity = VisualDensity.compact, + }); + + factory CustomChip.httpMethod(String method) { + Color color; + switch (method.toUpperCase()) { + case 'GET': + color = kColorHttpMethodGet; + break; + case 'HEAD': + color = kColorHttpMethodHead; + break; + case 'POST': + color = kColorHttpMethodPost; + break; + case 'PUT': + color = kColorHttpMethodPut; + break; + case 'PATCH': + color = kColorHttpMethodPatch; + break; + case 'DELETE': + color = kColorHttpMethodDelete; + break; + default: + color = Colors.grey.shade700; + } + return CustomChip( + label: method.toUpperCase(), + backgroundColor: color.withOpacity(0.1), + textColor: color, + borderColor: color, + ); + } + + factory CustomChip.statusCode(int status) { + Color color; + if (status >= 200 && status < 300) { + color = kColorStatusCode200; + } else if (status >= 300 && status < 400) { + color = kColorStatusCode300; + } else if (status >= 400 && status < 500) { + color = kColorStatusCode400; + } else if (status >= 500) { + color = kColorStatusCode500; + } else { + color = kColorStatusCodeDefault; + } + + String reason = kResponseCodeReasons[status] ?? ''; + String label = reason.isNotEmpty ? '$status - $reason' : '$status'; + return CustomChip( + label: label, + backgroundColor: color.withOpacity(0.1), + textColor: color, + borderColor: color, + ); + } + + factory CustomChip.contentType(String contentType) { + return CustomChip( + label: contentType, + backgroundColor: Colors.blue.withOpacity(0.1), + textColor: Colors.blue, + borderColor: Colors.blue, + ); + } + + factory CustomChip.tag(String tag, ColorScheme colorScheme) { + return CustomChip( + label: tag, + fontSize: 10, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + backgroundColor: colorScheme.primary.withOpacity(0.1), + textColor: colorScheme.primary, + borderColor: colorScheme.primary.withOpacity(0.3), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: backgroundColor ?? Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: borderColor ?? Theme.of(context).colorScheme.outline, + width: 1.5, + ), + ), + child: Text( + label, + style: TextStyle( + fontWeight: fontWeight, + color: textColor ?? Theme.of(context).colorScheme.onSurface, + fontSize: fontSize, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/common_widgets.dart b/lib/screens/explorer/common_widgets/common_widgets.dart new file mode 100644 index 000000000..c25d573cb --- /dev/null +++ b/lib/screens/explorer/common_widgets/common_widgets.dart @@ -0,0 +1,8 @@ +export 'api_search_bar.dart'; +export 'url_card.dart'; +export 'response_card.dart'; +export 'url_card.dart'; +export 'template_card.dart'; +export 'card_title.dart'; +export 'card_description.dart'; +export 'chip.dart'; \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/fetch_templates_button.dart b/lib/screens/explorer/common_widgets/fetch_templates_button.dart new file mode 100644 index 000000000..05fceb337 --- /dev/null +++ b/lib/screens/explorer/common_widgets/fetch_templates_button.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/providers/templates_provider.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; + +class FetchTemplatesButton extends ConsumerWidget { + const FetchTemplatesButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final templatesState = ref.watch(templatesProvider); + + return ElevatedButton( + onPressed: templatesState.isLoading + ? null + : () async { + await ref.read(templatesProvider.notifier).fetchTemplatesFromGitHub(); + final newState = ref.read(templatesProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + newState.error ?? + 'New templates fetched successfully!', + style: const TextStyle(fontSize: 14), + ), + backgroundColor: newState.error != null + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + behavior: SnackBarBehavior.floating, + width: 400, + padding: kP12, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: templatesState.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Fetch Latest'), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/response_card.dart b/lib/screens/explorer/common_widgets/response_card.dart new file mode 100644 index 000000000..028a0f0bf --- /dev/null +++ b/lib/screens/explorer/common_widgets/response_card.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'chip.dart'; + +/// Displays a list of headers with enabled/disabled status +class RequestHeadersCard extends StatelessWidget { + final String title; + final List? headers; + final List? isEnabledList; + + const RequestHeadersCard({ + super.key, + required this.title, + this.headers, + this.isEnabledList, + }); + + @override + Widget build(BuildContext context) { + if (headers == null || headers!.isEmpty) return const SizedBox.shrink(); + + return StyledCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + kVSpacer8, + ...List.generate( + headers!.length, + (i) => Padding( + padding: kPv2, + child: Row( + children: [ + Icon( + isEnabledList?[i] ?? true ? Icons.check_box : Icons.check_box_outline_blank, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + kHSpacer8, + Text(headers![i].name, style: const TextStyle(fontWeight: FontWeight.w500)), + kHSpacer8, + Text( + headers![i].value, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Displays a list of query parameters with enabled/disabled status +class RequestParamsCard extends StatelessWidget { + final String title; + final List? params; + final List? isEnabledList; + + const RequestParamsCard({ + super.key, + required this.title, + this.params, + this.isEnabledList, + }); + + @override + Widget build(BuildContext context) { + if (params == null || params!.isEmpty) return const SizedBox.shrink(); + + return StyledCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + kVSpacer8, + ...List.generate( + params!.length, + (i) => Padding( + padding: kPv2, + child: Row( + children: [ + Icon( + isEnabledList?[i] ?? true ? Icons.check_box : Icons.check_box_outline_blank, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + kHSpacer8, + Text(params![i].name, style: const TextStyle(fontWeight: FontWeight.w500)), + kHSpacer8, + Text( + params![i].value, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Displays the request body with an optional content type label +class RequestBodyCard extends StatelessWidget { + final String title; + final String? body; + final String? contentType; + final bool showCopyButton; + + const RequestBodyCard({ + super.key, + required this.title, + this.body, + this.contentType, + this.showCopyButton = false, + }); + + @override + Widget build(BuildContext context) { + if (body == null || body!.isEmpty) return const SizedBox.shrink(); + + return StyledCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + kHSpacer8, + if (contentType != null) + CustomChip.contentType(contentType!), + ], + ), + kVSpacer8, + Container( + width: double.infinity, + padding: kP12, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: kBorderRadius12, + ), + child: Text( + body!, + style: kCodeStyle.copyWith(color: Theme.of(context).colorScheme.onSurface), + ), + ), + ], + ), + ); + } +} + +/// A reusable card widget with consistent styling +class StyledCard extends StatelessWidget { + final Widget child; + + const StyledCard({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.surfaceContainerHighest), + borderRadius: kBorderRadius12, + ), + child: Padding(padding: kP12, child: child), + ); + } +} + +/// Displays the response body along with summary information (status, message, time) +class ResponseBodyCard extends StatelessWidget { + final HttpResponseModel? httpResponseModel; + final int? responseStatus; + final String? message; + final String? body; + + const ResponseBodyCard({ + super.key, + this.httpResponseModel, + this.responseStatus, + this.message, + this.body, + }); + + @override + Widget build(BuildContext context) { + final statusCode = httpResponseModel?.statusCode ?? responseStatus; + final responseTime = httpResponseModel?.time?.inMilliseconds ?? 0; + final responseBody = body ?? httpResponseModel?.formattedBody ?? httpResponseModel?.body ?? ''; + + // Hide the card if there's no relevant data to display + if (statusCode == null && responseBody.isEmpty && (message == null || message!.isEmpty)) { + return const SizedBox.shrink(); + } + + return StyledCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text(kLabelResponseBody, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + kHSpacer8, + if (statusCode != null) + CustomChip.statusCode(statusCode), + const Spacer(), + if (responseTime > 0) + Text('${responseTime}ms', style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)) + ), + ], + ), + if (message != null && message!.isEmpty) kVSpacer8, + if (message != null && message!.isNotEmpty) ...[ + kVSpacer8, + Text(message!, style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)) + ), + ], + if (responseBody.isNotEmpty) ...[ + kVSpacer10, + Container( + width: double.infinity, + padding: kP12, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: kBorderRadius12, + ), + child: Text( + responseBody, + style: kCodeStyle.copyWith(color: Theme.of(context).colorScheme.onSurface), + ), + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/template_card.dart b/lib/screens/explorer/common_widgets/template_card.dart new file mode 100644 index 000000000..338ca9ad0 --- /dev/null +++ b/lib/screens/explorer/common_widgets/template_card.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; +import 'card_title.dart'; +import 'card_description.dart'; +import 'chip.dart'; + +class TemplateCard extends StatelessWidget { + final ApiTemplate template; + final VoidCallback? onTap; + + const TemplateCard({ + Key? key, + required this.template, + this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 384), + child: Card( + margin: const EdgeInsets.all(8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: colorScheme.outline.withOpacity(0.2), + width: 1, + ), + ), + color: colorScheme.surface, + elevation: 0.8, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: MediaQuery.of(context).size.width > 600 ? 16 : 12, + vertical: MediaQuery.of(context).size.width > 600 ? 16 : 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: CardTitle( + title: template.info.title, + icon: Icons.api, // //currently no icons in the templates so icon always Icons.api + iconColor: colorScheme.primary, + ), + ), + Expanded( + flex: 5, + child: CardDescription( + description: template.info.description.isEmpty + ? 'No description' + : template.info.description, + maxLines: 5, + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.bottomLeft, + child: Wrap( + spacing: 8, + runSpacing: 4, + children: template.info.tags + .take(5) + .map((tag) => CustomChip.tag(tag, colorScheme)) + .toList(), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/common_widgets/url_card.dart b/lib/screens/explorer/common_widgets/url_card.dart new file mode 100644 index 000000000..6eaff222f --- /dev/null +++ b/lib/screens/explorer/common_widgets/url_card.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash/models/models.dart'; +import 'chip.dart'; +import '../import.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; + +class UrlCard extends ConsumerWidget { + final RequestModel? requestModel; + + const UrlCard({ + super.key, + required this.requestModel, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final importedData = importRequestData(requestModel); + final httpRequestModel = importedData.httpRequestModel; + final url = httpRequestModel?.url ?? ''; + final method = httpRequestModel?.method.toString().split('.').last.toUpperCase() ?? 'GET'; + + return Card( + color: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 20), + child: Row( + children: [ + CustomChip.httpMethod(method), + kHSpacer10, + Expanded( + child: Text( + url, + style: const TextStyle(color: Colors.blue), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + kHSpacer20, + ElevatedButton( + onPressed: () { + if (httpRequestModel != null) { + ref.read(collectionStateNotifierProvider.notifier).addRequestModel( + httpRequestModel, + name: requestModel?.name ?? 'Imported Request', + ); + ScaffoldMessenger.of(context).showSnackBar( //SnackBar notification + getSnackBar( + 'Request "${requestModel?.name ?? 'Imported Request'}" imported successfully', + small: false, + color: Theme.of(context).colorScheme.primary, + ), + ); + ref.read(navRailIndexStateProvider.notifier).state = 0; // Navigate to HomePage ind 0 + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('Import'), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/description_body.dart b/lib/screens/explorer/description/description_body.dart new file mode 100644 index 000000000..e1296161e --- /dev/null +++ b/lib/screens/explorer/description/description_body.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'request_pane.dart'; +import 'description_pane.dart'; +import 'explorer_split_view.dart'; + +class DescriptionBody extends StatefulWidget { + final ApiTemplate template; + + const DescriptionBody({ + super.key, + required this.template, + }); + + @override + State createState() => _DescriptionBodyState(); +} + +class _DescriptionBodyState extends State { + RequestModel? _selectedRequest; + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.background, + child: ExplorerSplitView( + sidebarWidget: RequestsPane( + requests: widget.template.requests, + onRequestSelected: (request) { + setState(() { + _selectedRequest = request; + }); + }, + ), + mainWidget: DescriptionPane(selectedRequest: _selectedRequest), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/description_header.dart b/lib/screens/explorer/description/description_header.dart new file mode 100644 index 000000000..6bf98b400 --- /dev/null +++ b/lib/screens/explorer/description/description_header.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; + +class DescriptionHeader extends StatelessWidget { + final Info info; + final VoidCallback onBack; + + const DescriptionHeader({ + super.key, + required this.info, + required this.onBack, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + color: theme.colorScheme.background, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + info.title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (info.description.isNotEmpty) + Text( + info.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/description_page.dart b/lib/screens/explorer/description/description_page.dart new file mode 100644 index 000000000..49eb088e9 --- /dev/null +++ b/lib/screens/explorer/description/description_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; +import 'description_header.dart'; +import 'description_body.dart'; + +class DescriptionPage extends StatelessWidget { + final ApiTemplate template; + final VoidCallback onBack; + + const DescriptionPage({ + super.key, + required this.template, + required this.onBack, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + DescriptionHeader( + info: template.info, + onBack: onBack, + ), + Expanded( + child: DescriptionBody(template: template), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/description_pane.dart b/lib/screens/explorer/description/description_pane.dart new file mode 100644 index 000000000..f294cd547 --- /dev/null +++ b/lib/screens/explorer/description/description_pane.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:apidash_core/apidash_core.dart'; +import '../common_widgets/common_widgets.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; + +class DescriptionPane extends StatelessWidget { + final RequestModel? selectedRequest; + + const DescriptionPane({super.key, this.selectedRequest}); + + @override + Widget build(BuildContext context) { + final httpRequestModel = selectedRequest?.httpRequestModel; + return Container( + color: Theme.of(context).colorScheme.background, + child: SingleChildScrollView( + padding: kP12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (selectedRequest?.name != null) ...[ + Text(selectedRequest!.name, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + if (selectedRequest?.description != null) + Text(selectedRequest!.description, style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))), + kVSpacer16, + ], + UrlCard( + requestModel: selectedRequest, + ), + kVSpacer16, + RequestHeadersCard( + title: kLabelHeaders, + headers: httpRequestModel?.headers, + isEnabledList: httpRequestModel?.isHeaderEnabledList, + ), + kVSpacer10, + RequestParamsCard( + title: kLabelQuery, + params: httpRequestModel?.params, + isEnabledList: httpRequestModel?.isParamEnabledList, + ), + kVSpacer10, + RequestBodyCard( + title: kLabelBody, + body: httpRequestModel?.body, + contentType: httpRequestModel?.bodyContentType?.toString().split('.').last, + ), + kVSpacer16, + ResponseBodyCard( + httpResponseModel: selectedRequest?.httpResponseModel, + responseStatus: selectedRequest?.responseStatus, + message: selectedRequest?.message, + body: selectedRequest?.httpResponseModel?.formattedBody ?? selectedRequest?.httpResponseModel?.body, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/explorer_split_view.dart b/lib/screens/explorer/description/explorer_split_view.dart new file mode 100644 index 000000000..09c4de333 --- /dev/null +++ b/lib/screens/explorer/description/explorer_split_view.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:multi_split_view/multi_split_view.dart'; + +class ExplorerSplitView extends StatefulWidget { + const ExplorerSplitView({ + super.key, + required this.sidebarWidget, + required this.mainWidget, + }); + + final Widget sidebarWidget; + final Widget mainWidget; + + @override + ExplorerSplitViewState createState() => ExplorerSplitViewState(); +} + +class ExplorerSplitViewState extends State { + final MultiSplitViewController _controller = MultiSplitViewController( + areas: [ + Area(id: "sidebar", min: 350, size: 400, max: 450), + Area(id: "main"), + ], + ); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MultiSplitViewTheme( + data: MultiSplitViewThemeData( + dividerThickness: 3, + dividerPainter: DividerPainters.background( + color: Theme.of(context).colorScheme.surfaceContainer, + highlightedColor: Theme.of(context).colorScheme.surfaceContainerHighest, + animationEnabled: false, + ), + ), + child: MultiSplitView( + controller: _controller, + sizeOverflowPolicy: SizeOverflowPolicy.shrinkFirst, + sizeUnderflowPolicy: SizeUnderflowPolicy.stretchLast, + builder: (context, area) { + return switch (area.id) { + "sidebar" => widget.sidebarWidget, + "main" => widget.mainWidget, + _ => Container(), + }; + }, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/request_pane.dart b/lib/screens/explorer/description/request_pane.dart new file mode 100644 index 000000000..055232742 --- /dev/null +++ b/lib/screens/explorer/description/request_pane.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:apidash_core/apidash_core.dart'; +import 'requests_card.dart'; +import 'package:apidash/models/models.dart'; + + +class RequestsPane extends StatefulWidget { + final List requests; + final Function(RequestModel)? onRequestSelected; + + const RequestsPane({ + super.key, + required this.requests, + this.onRequestSelected, + }); + + @override + State createState() => _RequestsPaneState(); +} + +class _RequestsPaneState extends State { + RequestModel? _selectedRequest; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: ListView.builder( + itemCount: widget.requests.length, + itemBuilder: (context, index) { + final request = widget.requests[index]; + final method = request.httpRequestModel?.method.toString().split('.').last.toUpperCase() ?? 'GET'; + return RequestCard( + title: request.name, + isSelected: _selectedRequest == request, + method: method, + onTap: () { + setState(() { + _selectedRequest = request; + }); + widget.onRequestSelected?.call(request); + }, + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/description/requests_card.dart b/lib/screens/explorer/description/requests_card.dart new file mode 100644 index 000000000..2707ce95b --- /dev/null +++ b/lib/screens/explorer/description/requests_card.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../common_widgets/chip.dart'; + +class RequestCard extends StatelessWidget { + final String title; + final bool isSelected; + final VoidCallback? onTap; + final String method; + + const RequestCard({ + super.key, + required this.title, + this.isSelected = false, + this.onTap, + this.method = 'GET', + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + color: isSelected + ? Theme.of(context).colorScheme.primary.withOpacity(0.2) + : Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + child: InkWell( + onTap: onTap, + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CustomChip.httpMethod(method), // HTTP method chip + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/doc/explorerREADME .MD b/lib/screens/explorer/doc/explorerREADME .MD new file mode 100644 index 000000000..2766d3a33 --- /dev/null +++ b/lib/screens/explorer/doc/explorerREADME .MD @@ -0,0 +1,143 @@ +# API Explorer Feature - APIDash (#PR 1) + +This temp README helps to monitor and understand the Proof of Concept (POC) for the **API Explorer** feature. + +The feature currently uses mock JSON data for templates (stored in `lib/screens/explorer/api_templates/mock`) and leverages reusable models from the `apidash_core` package to ensure consistency. It is built with Flutter, Riverpod for state management, and the `multi_split_view` package for a resizable split view layout. + +## Key Functionality + +### Current Implementation + +- **Template Browsing**: Displays API templates in a responsive grid, loaded from mock JSON files. +- **Template Details**: Provides a split view to show a list of requests and detailed information about the selected request. +- **Request Import**: Allows users to import a request into the main application with a single click. +- **Search (Placeholder)**: Includes a search bar for filtering templates (to be implemented). +- **Navigation**: Seamlessly integrates with the APIDash dashboard, accessible via the "Explorer" navigation option. + +## Key Files and Their Functionality + +### Models + +**Current Implementation**: + +The API Explorer reuses models from `apidash_core` and defines additional models specific to the feature. These models ensure consistency and enable serialization to/from JSON. + +1. `lib/models/explorer_model.dart` + + - **Purpose**: Defines the `ApiTemplate` and `Info` classes for representing API templates and their metadata. + - **Key Classes**: + - `ApiTemplate`: Contains an `Info` object (title, description, tags) and a list of `RequestModel`s. + - `Info`: Metadata for a template, serializable to/from JSON. + - **Functionality**: + - Parses JSON data from mock files into `ApiTemplate` objects. + - Supports future extensions (e.g., adding category or version fields to `Info`). + - **Reusability**: Designed to be extensible for real API data and reusable across other features. + +**Reusability Note**: The reuse of `RequestModel`, `HttpRequestModel`, and `HttpResponseModel` from `apidash_core` ensures consistency across APIDash features, reducing code duplication and simplifying maintenance. + +### Service + +1. `lib/services/templates_service.dart` + - **Purpose**: Loads API templates from mock JSON files or (in the future) a GitHub repo. + - **Key Methods**: + - `loadTemplates()`: Reads JSON files from `lib/screens/explorer/api_templates/mock` and parses them into `ApiTemplate` objects. + - `fetchTemplatesFromApi()`: Placeholder for fetching templates from a remote API. + - **Functionality**: + - Asynchronously loads templates using `rootBundle`. + - Handles errors gracefully, returning an empty list if loading fails. + - Filters JSON files from the asset manifest. + - **Future Potential**: + - Implement `fetchTemplatesFromrepo` for dynamic data. + - Add Hive to reduce redundant loads. + +### Important Widgets + +1. `lib/screens/explorer/explorer_page.dart` + + - **Purpose**: The main entry point for the API Explorer, managing navigation between the explorer grid and description page. + - **Functionality**: + - Toggles between the explorer view (`ExplorerHeader` + `ExplorerBody`) and the description view (`DescriptionPage`) using local state. + - Passes the selected template to `DescriptionPage` and handles back navigation. + - **Key Role**: Orchestrates the user flow within the API Explorer. + +2. `lib/screens/explorer/explorer_body.dart` + + - **Purpose**: Renders a searchable grid of API template cards. + - **Functionality**: + - Uses a `FutureBuilder` to load templates via `TemplatesService.loadTemplates()`. + - Displays loading, error, or empty states. + - Renders `TemplateCard` widgets in a responsive `GridView`. + - Includes an `ApiSearchBar` (placeholder for search filtering). + - **Key Role**: Provides the primary browsing interface. + +3. `lib/screens/explorer/description/description_page.dart` + + - **Purpose**: Displays detailed information about a selected API template. + - **Functionality**: + - Comprises a `DescriptionHeader` (title, description, back button) and a `DescriptionBody` (split view of requests and details). + - Receives the selected `ApiTemplate` and a callback to return to the explorer. + - **Key Role**: Facilitates in-depth exploration of templates. + +4. `lib/screens/explorer/description/description_body.dart` + + - **Purpose**: Manages the split view layout for the description page. + - **Functionality**: + - Uses `ExplorerSplitView` to create a resizable split view with `RequestsPane` (list of requests) and `DescriptionPane` (request details). + - Maintains state for the selected `RequestModel`. + - **Key Role**: Enables side-by-side viewing of requests and their details. + +5. `lib/screens/explorer/common_widgets/template_card.dart` + + - **Purpose**: Represents an API template in the explorer grid. + - **Functionality**: + - Displays the template's title (`CardTitle`), description (`CardDescription`), and tags (`CustomChip.tag`). + - Triggers navigation to the description page on tap. + - **Key Role**: Provides a visually appealing and informative template preview. + +6. `lib/screens/explorer/common_widgets/url_card.dart` + + - **Purpose**: Displays a request's URL, HTTP method, and an "Import" button. + - **Functionality**: + - Uses Riverpod to add the request to the collection via `collectionStateNotifierProvider`. + - Shows a `SnackBar` on successful import and navigates to the `HomePage`. + - **Key Role**: Enables seamless integration with the main application. + +7. `lib/screens/explorer/common_widgets/api_search_bar.dart` + + - **Purpose**: Provides a search input field for filtering templates. + - **Functionality**: + - Includes a search icon and a clear button (when text is entered). + - Currently a placeholder with TODOs for `onChanged` and `onClear` callbacks. + - **Key Role**: Foundation for future search functionality. + +8. `lib/screens/explorer/common_widgets/chip.dart` + + - **Purpose**: A versatile chip widget for HTTP methods, status codes, content types, and tags. + - **Functionality**: + - Provides factory constructors (e.g., `httpMethod`, `statusCode`, `tag`) with context-specific colors. + - Used across `TemplateCard`, `RequestCard`, and other widgets for consistent styling. + - **Key Role**: Enhances visual clarity and consistency. + +## Future Checklist + +The following tasks are planned to enhance the API Explorer feature: + +- [ ] **Implement Search Functionality**: + - Add filtering logic to `ApiSearchBar` to search templates by title, description, or tags. + - Update `ExplorerBody` to reflect filtered results dynamically. +- [ ] **Add Global State Management**: + - Introduce a Riverpod provider for managing selected template, selected request, and search query. + - Refactor `ExplorerPage` and `DescriptionBody` to use the provider. +- [ ] **Fetch Templates from GitHub Repo**: + - Implement `TemplatesService.fetchTemplatesFromRepo` to load templates from a remote endpoint. + - Add caching (e.g., using `hive`). +- [ ] **Enhance UI/UX**: + - Add sorting and filtering options to `ExplorerHeader` (e.g., by category or tag). + - Introduce template-specific icons in `TemplateCard`. + - Provide a placeholder UI in `DescriptionPane` when no request is selected. +- [ ] **Improve Performance**: + - Implement pagination or lazy loading in `ExplorerBody` for large template sets. + - Optimize `GridView` rendering for better scrolling performance. +- [ ] **Community Contributions** via API Dash: + - An in-app Documentation Editor will allow users to upload API specs, edit auto-generated JSON templates, download them, and (in the future) trigger GitHub pull requests directly . + \ No newline at end of file diff --git a/lib/screens/explorer/explorer_body.dart b/lib/screens/explorer/explorer_body.dart new file mode 100644 index 000000000..fd2e02c20 --- /dev/null +++ b/lib/screens/explorer/explorer_body.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/providers/templates_provider.dart'; +import 'package:apidash/screens/explorer/common_widgets/common_widgets.dart'; + +class ExplorerBody extends ConsumerWidget { + final Function(ApiTemplate)? onCardTap; + + const ExplorerBody({ + super.key, + this.onCardTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final templatesState = ref.watch(templatesProvider); + + return Container( + color: Theme.of(context).colorScheme.background, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: ApiSearchBar( + hintText: 'Search Explorer', + onChanged: (value) { + // TODO: Implement search filtering + }, + onClear: () { + // TODO: Handle clear action + }, + ), + ), + ), + ), + ), + Expanded( + child: templatesState.isLoading + ? const Center(child: CircularProgressIndicator()) + : templatesState.error != null + ? Center(child: Text('Error: ${templatesState.error}')) + : templatesState.templates.isEmpty + ? const Center(child: Text('No templates found')) + : GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 384, + childAspectRatio: 1.3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: templatesState.templates.length, + itemBuilder: (context, index) { + final template = templatesState.templates[index]; + return TemplateCard( + template: template, + onTap: () => onCardTap?.call(template), + ); + }, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/explorer_header.dart b/lib/screens/explorer/explorer_header.dart new file mode 100644 index 000000000..86099c9bc --- /dev/null +++ b/lib/screens/explorer/explorer_header.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/screens/explorer/common_widgets/fetch_templates_button.dart'; + +class ExplorerHeader extends StatelessWidget { + const ExplorerHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.background, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'API EXPLORER', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const FetchTemplatesButton(), + ], + ), + ); + } +} diff --git a/lib/screens/explorer/explorer_page.dart b/lib/screens/explorer/explorer_page.dart new file mode 100644 index 000000000..506471bd7 --- /dev/null +++ b/lib/screens/explorer/explorer_page.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; +import 'explorer_header.dart'; +import 'explorer_body.dart'; +import 'description/description_page.dart'; + +class ExplorerPage extends StatefulWidget { + const ExplorerPage({super.key}); + + @override + State createState() => _ExplorerPageState(); +} + +class _ExplorerPageState extends State { + bool _showDescription = false; + ApiTemplate? _selectedTemplate; + + void _navigateToDescription(ApiTemplate template) { + setState(() { + _showDescription = true; + _selectedTemplate = template; + }); + } + + void _navigateBackToExplorer() { + setState(() { + _showDescription = false; + _selectedTemplate = null; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _showDescription + ? DescriptionPage( + template: _selectedTemplate!, + onBack: _navigateBackToExplorer, + ) + : Column( + children: [ + const SizedBox(height: 60, child: ExplorerHeader()), + Expanded(child: ExplorerBody(onCardTap: _navigateToDescription)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/explorer/import.dart b/lib/screens/explorer/import.dart new file mode 100644 index 000000000..d54362c9c --- /dev/null +++ b/lib/screens/explorer/import.dart @@ -0,0 +1,26 @@ +import 'package:apidash/models/models.dart'; +import 'package:apidash_core/apidash_core.dart'; + +class ImportedRequestData { + final HttpRequestModel? httpRequestModel; + + ImportedRequestData(this.httpRequestModel); + + // Static default instance for null cases + static final ImportedRequestData _default = ImportedRequestData( + HttpRequestModel( + url: '', + method: HTTPVerb.get, + headers: [], + params: [], + ), + ); + + // Factory constructor to return default instance if null + factory ImportedRequestData.empty() => _default; +} + +ImportedRequestData importRequestData(RequestModel? requestModel) { + final httpRequestModel = requestModel?.httpRequestModel ?? HttpRequestModel(); + return ImportedRequestData(httpRequestModel); +} \ No newline at end of file diff --git a/lib/services/hive_services.dart b/lib/services/hive_services.dart index 5ca476073..3f9b5c38d 100644 --- a/lib/services/hive_services.dart +++ b/lib/services/hive_services.dart @@ -11,6 +11,9 @@ const String kHistoryMetaBox = "apidash-history-meta"; const String kHistoryBoxIds = "historyIds"; const String kHistoryLazyBox = "apidash-history-lazy"; +const String kTemplatesBox = "apidash-templates"; +const String kTemplatesKey = "templates"; + Future initHiveBoxes( bool initializeUsingPath, String? workspaceFolderPath, @@ -38,6 +41,7 @@ Future openHiveBoxes() async { await Hive.openBox(kEnvironmentBox); await Hive.openBox(kHistoryMetaBox); await Hive.openLazyBox(kHistoryLazyBox); + await Hive.openBox(kTemplatesBox); return true; } catch (e) { debugPrint("ERROR OPEN HIVE BOXES: $e"); @@ -59,6 +63,9 @@ Future clearHiveBoxes() async { if (Hive.isBoxOpen(kHistoryLazyBox)) { await Hive.lazyBox(kHistoryLazyBox).clear(); } + if (Hive.isBoxOpen(kTemplatesBox)) { + await Hive.box(kTemplatesBox).clear(); + } } catch (e) { debugPrint("ERROR CLEAR HIVE BOXES: $e"); } @@ -78,6 +85,9 @@ Future deleteHiveBoxes() async { if (Hive.isBoxOpen(kHistoryLazyBox)) { await Hive.lazyBox(kHistoryLazyBox).deleteFromDisk(); } + if (Hive.isBoxOpen(kTemplatesBox)) { + await Hive.box(kTemplatesBox).deleteFromDisk(); + } await Hive.close(); } catch (e) { debugPrint("ERROR DELETE HIVE BOXES: $e"); @@ -91,6 +101,7 @@ class HiveHandler { late final Box environmentBox; late final Box historyMetaBox; late final LazyBox historyLazyBox; + late final Box templatesBox; HiveHandler() { debugPrint("Trying to open Hive boxes"); @@ -98,6 +109,7 @@ class HiveHandler { environmentBox = Hive.box(kEnvironmentBox); historyMetaBox = Hive.box(kHistoryMetaBox); historyLazyBox = Hive.lazyBox(kHistoryLazyBox); + templatesBox = Hive.box(kTemplatesBox); } dynamic getIds() => dataBox.get(kKeyDataBoxIds); @@ -150,6 +162,7 @@ class HiveHandler { await environmentBox.clear(); await historyMetaBox.clear(); await historyLazyBox.clear(); + await templatesBox.clear(); } Future removeUnused() async { @@ -172,4 +185,8 @@ class HiveHandler { } } } + + dynamic getTemplates() => templatesBox.get(kTemplatesKey); + Future setTemplates(List>? templates) => + templatesBox.put(kTemplatesKey, templates); } diff --git a/lib/services/services.dart b/lib/services/services.dart index 7fa8128d6..a17b38dac 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -2,3 +2,5 @@ export 'hive_services.dart'; export 'history_service.dart'; export 'window_services.dart'; export 'shared_preferences_services.dart'; +export 'templates_service.dart'; +export 'templates_service.dart'; \ No newline at end of file diff --git a/lib/services/templates_service.dart b/lib/services/templates_service.dart new file mode 100644 index 000000000..816abf0f2 --- /dev/null +++ b/lib/services/templates_service.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:http/http.dart' as http; +import 'package:archive/archive.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/services/services.dart'; + +class TemplatesService { + static const String githubRepoOwner = 'BalaSubramaniam12007'; + static const String githubRepoName = 'api-sample-library'; + + static Future> loadTemplates() async { + // Load cached or default (mock) templates from Hive + final cachedTemplates = await loadCachedTemplates(); + if (cachedTemplates.isNotEmpty) { + return cachedTemplates; + } + // Fallback to mock templates (initializes Hive with mocks) + return await _loadMockTemplates(); + } + + static Future> fetchTemplatesFromGitHub() async { + try { + final releaseUrl = 'https://api.github.com/repos/$githubRepoOwner/$githubRepoName/releases/latest'; + final releaseResponse = await http.get( + Uri.parse(releaseUrl), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + if (releaseResponse.statusCode != 200) { + throw Exception('Failed to fetch latest release: ${releaseResponse.statusCode}'); + } + + final releaseData = jsonDecode(releaseResponse.body); + final assets = releaseData['assets'] as List; + final zipAsset = assets.firstWhere( + (asset) => asset['name'] == 'api_templates.zip', + orElse: () => throw Exception('No api_templates.zip found in release'), + ); + + final zipUrl = zipAsset['browser_download_url'] as String; + final zipResponse = await http.get(Uri.parse(zipUrl)); + if (zipResponse.statusCode != 200) { + throw Exception('Failed to download zip: ${zipResponse.statusCode}'); + } + + final zipBytes = zipResponse.bodyBytes; + final archive = ZipDecoder().decodeBytes(zipBytes); + List newTemplates = []; + for (final file in archive) { + if (file.isFile && file.name.endsWith('.json')) { + final jsonString = utf8.decode(file.content as List); + final jsonData = jsonDecode(jsonString); + newTemplates.add(ApiTemplate.fromJson(jsonData)); + } + } + + if (newTemplates.isNotEmpty) { + final existingTemplates = await loadCachedTemplates(); + final combinedTemplates = _appendTemplates(existingTemplates, newTemplates); + await _cacheTemplates(combinedTemplates); + return combinedTemplates; + } + return await loadCachedTemplates(); // Fallback to cached/mock + } catch (e) { + print('Error fetching templates from GitHub: $e'); + // Fallback to cached or mock templates + return await loadCachedTemplates(); + } + } + + static Future> loadCachedTemplates() async { + try { + final templateJsons = hiveHandler.getTemplates(); + if (templateJsons != null && templateJsons is List) { + return templateJsons + .map((json) => ApiTemplate.fromJson(json as Map)) + .toList(); + } + // If no templates in Hive, initialize with mocks + return await _loadMockTemplates(); + } catch (e) { + print('Error loading cached templates: $e'); + return await _loadMockTemplates(); + } + } + + static Future hasCachedTemplates() async { + final templates = await loadCachedTemplates(); + return templates.isNotEmpty; + } + + static Future _cacheTemplates(List templates) async { + try { + final templateJsons = templates.map((t) => t.toJson()).toList(); + await hiveHandler.setTemplates(templateJsons); + } catch (e) { + print('Error caching templates: $e'); + } + } + + static Future> _loadMockTemplates() async { + const String templatesDir = 'lib/screens/explorer/api_templates/mock'; + try { + final manifestContent = await rootBundle.loadString('AssetManifest.json'); + final Map manifestMap = jsonDecode(manifestContent); + final jsonFiles = manifestMap.keys + .where((key) => key.startsWith(templatesDir) && key.endsWith('.json')) + .toList(); + List templates = []; + for (String filePath in jsonFiles) { + final String jsonString = await rootBundle.loadString(filePath); + final Map jsonData = jsonDecode(jsonString); + templates.add(ApiTemplate.fromJson(jsonData)); + } + + if (templates.isNotEmpty) { + await _cacheTemplates(templates); + return templates; + } + return _getFallbackTemplates(); + } catch (e) { + print('Error loading mock templates: $e'); + return _getFallbackTemplates(); + } + } + + static List _appendTemplates( + List existing, List newTemplates) { + final existingTitles = existing.map((t) => t.info.title.toLowerCase()).toSet(); + final combined = [...existing]; + for (final template in newTemplates) { + if (!existingTitles.contains(template.info.title.toLowerCase())) { + combined.add(template); + existingTitles.add(template.info.title.toLowerCase()); + } + } + return combined; + } + + static List _getFallbackTemplates() { + return [ + ApiTemplate( + info: Info( + title: 'Default Template', + description: 'A fallback template when no templates are available.', + tags: ['default'], + ), + requests: [], + ), + ]; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 28ffae063..bbb23f84f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,4 +94,5 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/ + - assets/ + - lib/screens/explorer/api_templates/mock/