diff --git a/lib/models/settings_model.dart b/lib/models/settings_model.dart index 74bc2ff9c..add981e57 100644 --- a/lib/models/settings_model.dart +++ b/lib/models/settings_model.dart @@ -17,6 +17,7 @@ class SettingsModel { this.historyRetentionPeriod = HistoryRetentionPeriod.oneWeek, this.workspaceFolderPath, this.isSSLDisabled = false, + this.proxySettings = const ProxySettings(), }); final bool isDark; @@ -32,6 +33,9 @@ class SettingsModel { final String? workspaceFolderPath; final bool isSSLDisabled; + // Proxy settings + final ProxySettings? proxySettings; + SettingsModel copyWith({ bool? isDark, bool? alwaysShowCollectionPaneScrollbar, @@ -45,15 +49,16 @@ class SettingsModel { HistoryRetentionPeriod? historyRetentionPeriod, String? workspaceFolderPath, bool? isSSLDisabled, + ProxySettings? proxySettings, }) { return SettingsModel( isDark: isDark ?? this.isDark, alwaysShowCollectionPaneScrollbar: alwaysShowCollectionPaneScrollbar ?? this.alwaysShowCollectionPaneScrollbar, size: size ?? this.size, + offset: offset ?? this.offset, defaultUriScheme: defaultUriScheme ?? this.defaultUriScheme, defaultCodeGenLang: defaultCodeGenLang ?? this.defaultCodeGenLang, - offset: offset ?? this.offset, saveResponses: saveResponses ?? this.saveResponses, promptBeforeClosing: promptBeforeClosing ?? this.promptBeforeClosing, activeEnvironmentId: activeEnvironmentId ?? this.activeEnvironmentId, @@ -61,6 +66,7 @@ class SettingsModel { historyRetentionPeriod ?? this.historyRetentionPeriod, workspaceFolderPath: workspaceFolderPath ?? this.workspaceFolderPath, isSSLDisabled: isSSLDisabled ?? this.isSSLDisabled, + proxySettings: proxySettings, ); } @@ -71,15 +77,16 @@ class SettingsModel { isDark: isDark, alwaysShowCollectionPaneScrollbar: alwaysShowCollectionPaneScrollbar, size: size, + offset: offset, defaultUriScheme: defaultUriScheme, defaultCodeGenLang: defaultCodeGenLang, - offset: offset, saveResponses: saveResponses, promptBeforeClosing: promptBeforeClosing, activeEnvironmentId: activeEnvironmentId, historyRetentionPeriod: historyRetentionPeriod, workspaceFolderPath: workspaceFolderPath, isSSLDisabled: isSSLDisabled, + proxySettings: proxySettings, ); } @@ -92,13 +99,16 @@ class SettingsModel { final dx = data["dx"] as double?; final dy = data["dy"] as double?; Size? size; + if (width != null && height != null) { size = Size(width, height); } Offset? offset; + if (dx != null && dy != null) { offset = Offset(dx, dy); } + final defaultUriSchemeStr = data["defaultUriScheme"] as String?; SupportedUriSchemes? defaultUriScheme; if (defaultUriSchemeStr != null) { @@ -109,6 +119,7 @@ class SettingsModel { // pass } } + final defaultCodeGenLangStr = data["defaultCodeGenLang"] as String?; CodegenLanguage? defaultCodeGenLang; if (defaultCodeGenLangStr != null) { @@ -119,6 +130,7 @@ class SettingsModel { // pass } } + final saveResponses = data["saveResponses"] as bool?; final promptBeforeClosing = data["promptBeforeClosing"] as bool?; final activeEnvironmentId = data["activeEnvironmentId"] as String?; @@ -132,9 +144,19 @@ class SettingsModel { // pass } } + final workspaceFolderPath = data["workspaceFolderPath"] as String?; final isSSLDisabled = data["isSSLDisabled"] as bool?; + ProxySettings? proxySettings; + if (data["proxySettings"] != null) { + try { + proxySettings = ProxySettings.fromJson(Map.from(data["proxySettings"])); + } catch (e) { + // pass + } + } + const sm = SettingsModel(); return sm.copyWith( @@ -151,6 +173,7 @@ class SettingsModel { historyRetentionPeriod ?? HistoryRetentionPeriod.oneWeek, workspaceFolderPath: workspaceFolderPath, isSSLDisabled: isSSLDisabled, + proxySettings: proxySettings, ); } @@ -170,6 +193,7 @@ class SettingsModel { "historyRetentionPeriod": historyRetentionPeriod.name, "workspaceFolderPath": workspaceFolderPath, "isSSLDisabled": isSSLDisabled, + "proxySettings": proxySettings?.toJson(), }; } @@ -194,7 +218,8 @@ class SettingsModel { other.activeEnvironmentId == activeEnvironmentId && other.historyRetentionPeriod == historyRetentionPeriod && other.workspaceFolderPath == workspaceFolderPath && - other.isSSLDisabled == isSSLDisabled; + other.isSSLDisabled == isSSLDisabled && + other.proxySettings == proxySettings; } @override @@ -213,6 +238,7 @@ class SettingsModel { historyRetentionPeriod, workspaceFolderPath, isSSLDisabled, + proxySettings, ); } } diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index 35bc4aa0c..db3b864e1 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -295,6 +295,7 @@ class CollectionStateNotifier substitutedHttpRequestModel, defaultUriScheme: defaultUriScheme, noSSL: noSSL, + proxySettings: ref.read(settingsProvider).proxySettings, ); late final RequestModel newRequestModel; diff --git a/lib/providers/settings_providers.dart b/lib/providers/settings_providers.dart index 6b64343aa..dccfbebb3 100644 --- a/lib/providers/settings_providers.dart +++ b/lib/providers/settings_providers.dart @@ -33,8 +33,10 @@ class ThemeStateNotifier extends StateNotifier { HistoryRetentionPeriod? historyRetentionPeriod, String? workspaceFolderPath, bool? isSSLDisabled, + ProxySettings? proxySettings, }) async { - state = state.copyWith( + + final newState = state.copyWith( isDark: isDark, alwaysShowCollectionPaneScrollbar: alwaysShowCollectionPaneScrollbar, size: size, @@ -47,7 +49,12 @@ class ThemeStateNotifier extends StateNotifier { historyRetentionPeriod: historyRetentionPeriod, workspaceFolderPath: workspaceFolderPath, isSSLDisabled: isSSLDisabled, + proxySettings: proxySettings, ); - await setSettingsToSharedPrefs(state); + + if (newState != state) { + state = newState; + await setSettingsToSharedPrefs(state); + } } } diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index eb4b60088..f164ced9b 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -50,6 +50,24 @@ class SettingsPage extends ConsumerWidget { ref.read(settingsProvider.notifier).update(isDark: value); }, ), + SwitchListTile( + title: const Text('Proxy Settings'), + subtitle: Text(ref.watch(settingsProvider).proxySettings != null + ? 'Enabled - ${ref.watch(settingsProvider).proxySettings!.host}:${ref.watch(settingsProvider).proxySettings!.port}' + : 'Disabled' + ), + value: ref.watch(settingsProvider).proxySettings != null, + onChanged: (bool value) { + if (!value) { + ref.read(settingsProvider.notifier).update(proxySettings: null); + } else { + showDialog( + context: context, + builder: (context) => const ProxySettingsDialog(), + ); + } + }, + ), SwitchListTile( hoverColor: kColorTransparent, title: const Text('Collection Pane Scrollbar Visiblity'), diff --git a/lib/widgets/dialog_proxy_settings.dart b/lib/widgets/dialog_proxy_settings.dart new file mode 100644 index 000000000..4e0a2d9ac --- /dev/null +++ b/lib/widgets/dialog_proxy_settings.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash_core/models/models.dart'; +import 'package:apidash/providers/settings_providers.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; + +class ProxySettingsDialog extends ConsumerStatefulWidget { + const ProxySettingsDialog({super.key}); + + @override + ConsumerState createState() => + _ProxySettingsDialogState(); +} + +class _ProxySettingsDialogState extends ConsumerState { + late TextEditingController _hostController; + late TextEditingController _portController; + late TextEditingController _usernameController; + late TextEditingController _passwordController; + late List _formFields; + + @override + void initState() { + super.initState(); + final settings = ref.read(settingsProvider); + final proxy = settings.proxySettings; + _hostController = TextEditingController(text: proxy?.host ?? ''); + _portController = TextEditingController(text: proxy?.port ?? ''); + _usernameController = TextEditingController(text: proxy?.username ?? ''); + _passwordController = TextEditingController(text: proxy?.password ?? ''); + + _initFormFields(); + } + + void _initFormFields() { + _formFields = [ + GenericFormField( + controller: _hostController, + labelText: 'Proxy Host', + hintText: 'e.g., localhost', + required: true, + ), + GenericFormField( + controller: _portController, + labelText: 'Proxy Port', + hintText: 'e.g., 8080', + required: true, + ), + GenericFormField( + controller: _usernameController, + labelText: 'Username', + hintText: 'Optional', + ), + GenericFormField( + controller: _passwordController, + labelText: 'Password', + hintText: 'Optional', + obscureText: true, + ), + ]; + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _saveSettings() { + if (_hostController.text.isNotEmpty && _portController.text.isNotEmpty) { + final newProxy = ProxySettings( + host: _hostController.text, + port: _portController.text, + username: _usernameController.text.isEmpty ? null : _usernameController.text, + password: _passwordController.text.isEmpty ? null : _passwordController.text, + ); + _updateProxySettings(newProxy); + Navigator.of(context).pop(); + } + else{ + _updateProxySettings(null); + Navigator.of(context).pop(); + } + } + + void _updateProxySettings(ProxySettings? newProxy) { + ref.read(settingsProvider.notifier).update(proxySettings: newProxy); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Proxy Settings'), + content: SingleChildScrollView( + child: GenericForm(fields: _formFields), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: _saveSettings, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index f64276042..b9dccb06e 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -17,6 +17,7 @@ export 'dialog_about.dart'; export 'dialog_history_retention.dart'; export 'dialog_import.dart'; export 'dialog_ok_cancel.dart'; +export 'dialog_proxy_settings.dart'; export 'dialog_rename.dart'; export 'dialog_text.dart'; export 'drag_and_drop_area.dart'; diff --git a/packages/apidash_core/lib/models/models.dart b/packages/apidash_core/lib/models/models.dart index a33c6fddc..1a4998b16 100644 --- a/packages/apidash_core/lib/models/models.dart +++ b/packages/apidash_core/lib/models/models.dart @@ -1,2 +1,3 @@ export 'http_request_model.dart'; export 'http_response_model.dart'; +export 'proxy_settings_model.dart'; diff --git a/packages/apidash_core/lib/models/proxy_settings_model.dart b/packages/apidash_core/lib/models/proxy_settings_model.dart new file mode 100644 index 000000000..9fded3506 --- /dev/null +++ b/packages/apidash_core/lib/models/proxy_settings_model.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'proxy_settings_model.freezed.dart'; +part 'proxy_settings_model.g.dart'; + +@freezed +class ProxySettings with _$ProxySettings { + const factory ProxySettings({ + @Default('') String host, + @Default('') String port, + String? username, + String? password, + }) = _ProxySettings; + + factory ProxySettings.fromJson(Map json) => + _$ProxySettingsFromJson(json); +} diff --git a/packages/apidash_core/lib/models/proxy_settings_model.freezed.dart b/packages/apidash_core/lib/models/proxy_settings_model.freezed.dart new file mode 100644 index 000000000..bc8b0a82d --- /dev/null +++ b/packages/apidash_core/lib/models/proxy_settings_model.freezed.dart @@ -0,0 +1,221 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'proxy_settings_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +ProxySettings _$ProxySettingsFromJson(Map json) { + return _ProxySettings.fromJson(json); +} + +/// @nodoc +mixin _$ProxySettings { + String get host => throw _privateConstructorUsedError; + String get port => throw _privateConstructorUsedError; + String? get username => throw _privateConstructorUsedError; + String? get password => throw _privateConstructorUsedError; + + /// Serializes this ProxySettings to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ProxySettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProxySettingsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProxySettingsCopyWith<$Res> { + factory $ProxySettingsCopyWith( + ProxySettings value, $Res Function(ProxySettings) then) = + _$ProxySettingsCopyWithImpl<$Res, ProxySettings>; + @useResult + $Res call({String host, String port, String? username, String? password}); +} + +/// @nodoc +class _$ProxySettingsCopyWithImpl<$Res, $Val extends ProxySettings> + implements $ProxySettingsCopyWith<$Res> { + _$ProxySettingsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProxySettings + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? host = null, + Object? port = null, + Object? username = freezed, + Object? password = freezed, + }) { + return _then(_value.copyWith( + host: null == host + ? _value.host + : host // ignore: cast_nullable_to_non_nullable + as String, + port: null == port + ? _value.port + : port // ignore: cast_nullable_to_non_nullable + as String, + username: freezed == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String?, + password: freezed == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ProxySettingsImplCopyWith<$Res> + implements $ProxySettingsCopyWith<$Res> { + factory _$$ProxySettingsImplCopyWith( + _$ProxySettingsImpl value, $Res Function(_$ProxySettingsImpl) then) = + __$$ProxySettingsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String host, String port, String? username, String? password}); +} + +/// @nodoc +class __$$ProxySettingsImplCopyWithImpl<$Res> + extends _$ProxySettingsCopyWithImpl<$Res, _$ProxySettingsImpl> + implements _$$ProxySettingsImplCopyWith<$Res> { + __$$ProxySettingsImplCopyWithImpl( + _$ProxySettingsImpl _value, $Res Function(_$ProxySettingsImpl) _then) + : super(_value, _then); + + /// Create a copy of ProxySettings + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? host = null, + Object? port = null, + Object? username = freezed, + Object? password = freezed, + }) { + return _then(_$ProxySettingsImpl( + host: null == host + ? _value.host + : host // ignore: cast_nullable_to_non_nullable + as String, + port: null == port + ? _value.port + : port // ignore: cast_nullable_to_non_nullable + as String, + username: freezed == username + ? _value.username + : username // ignore: cast_nullable_to_non_nullable + as String?, + password: freezed == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProxySettingsImpl implements _ProxySettings { + const _$ProxySettingsImpl( + {this.host = '', this.port = '', this.username, this.password}); + + factory _$ProxySettingsImpl.fromJson(Map json) => + _$$ProxySettingsImplFromJson(json); + + @override + @JsonKey() + final String host; + @override + @JsonKey() + final String port; + @override + final String? username; + @override + final String? password; + + @override + String toString() { + return 'ProxySettings(host: $host, port: $port, username: $username, password: $password)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProxySettingsImpl && + (identical(other.host, host) || other.host == host) && + (identical(other.port, port) || other.port == port) && + (identical(other.username, username) || + other.username == username) && + (identical(other.password, password) || + other.password == password)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, host, port, username, password); + + /// Create a copy of ProxySettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProxySettingsImplCopyWith<_$ProxySettingsImpl> get copyWith => + __$$ProxySettingsImplCopyWithImpl<_$ProxySettingsImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ProxySettingsImplToJson( + this, + ); + } +} + +abstract class _ProxySettings implements ProxySettings { + const factory _ProxySettings( + {final String host, + final String port, + final String? username, + final String? password}) = _$ProxySettingsImpl; + + factory _ProxySettings.fromJson(Map json) = + _$ProxySettingsImpl.fromJson; + + @override + String get host; + @override + String get port; + @override + String? get username; + @override + String? get password; + + /// Create a copy of ProxySettings + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProxySettingsImplCopyWith<_$ProxySettingsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/apidash_core/lib/models/proxy_settings_model.g.dart b/packages/apidash_core/lib/models/proxy_settings_model.g.dart new file mode 100644 index 000000000..383e99761 --- /dev/null +++ b/packages/apidash_core/lib/models/proxy_settings_model.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'proxy_settings_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ProxySettingsImpl _$$ProxySettingsImplFromJson(Map json) => + _$ProxySettingsImpl( + host: json['host'] as String? ?? '', + port: json['port'] as String? ?? '', + username: json['username'] as String?, + password: json['password'] as String?, + ); + +Map _$$ProxySettingsImplToJson(_$ProxySettingsImpl instance) => + { + 'host': instance.host, + 'port': instance.port, + 'username': instance.username, + 'password': instance.password, + }; diff --git a/packages/apidash_core/lib/services/http_client_manager.dart b/packages/apidash_core/lib/services/http_client_manager.dart index bec23214f..4f9896e05 100644 --- a/packages/apidash_core/lib/services/http_client_manager.dart +++ b/packages/apidash_core/lib/services/http_client_manager.dart @@ -3,11 +3,38 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:http/io_client.dart'; +import 'package:apidash_core/models/models.dart'; + +http.Client createCustomHttpClient({ + bool noSSL = false, + ProxySettings? proxySettings, +}) { + if (kIsWeb) { + return http.Client(); + } + + var ioClient = HttpClient(); + + // Configure SSL + if (noSSL) { + ioClient.badCertificateCallback = (X509Certificate cert, String host, int port) => true; + } + + // Configure proxy if enabled + if (proxySettings != null) { + // Set proxy server + ioClient.findProxy = (uri) { + return 'PROXY ${proxySettings.host}:${proxySettings.port}'; + }; + + // Configure proxy authentication if credentials are provided + if (proxySettings.username != null && proxySettings.password != null) { + ioClient.authenticate = (Uri url, String scheme, String? realm) async { + return true; + }; + } + } -http.Client createHttpClientWithNoSSL() { - var ioClient = HttpClient() - ..badCertificateCallback = - (X509Certificate cert, String host, int port) => true; return IOClient(ioClient); } @@ -26,9 +53,13 @@ class HttpClientManager { http.Client createClient( String requestId, { bool noSSL = false, + ProxySettings? proxySettings, }) { - final client = - (noSSL && !kIsWeb) ? createHttpClientWithNoSSL() : http.Client(); + final client = createCustomHttpClient( + noSSL: noSSL, + proxySettings: proxySettings, + ); + _clients[requestId] = client; return client; } diff --git a/packages/apidash_core/lib/services/http_service.dart b/packages/apidash_core/lib/services/http_service.dart index ad06a21d4..2af1bcc38 100644 --- a/packages/apidash_core/lib/services/http_service.dart +++ b/packages/apidash_core/lib/services/http_service.dart @@ -18,8 +18,13 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequest( HttpRequestModel requestModel, { SupportedUriSchemes defaultUriScheme = kDefaultUriScheme, bool noSSL = false, + ProxySettings? proxySettings, }) async { - final client = httpClientManager.createClient(requestId, noSSL: noSSL); + final client = httpClientManager.createClient( + requestId, + noSSL: noSSL, + proxySettings: proxySettings, + ); (Uri?, String?) uriRec = getValidRequestUri( requestModel.url, diff --git a/packages/apidash_core/test/models/proxy_settings_model_test.dart b/packages/apidash_core/test/models/proxy_settings_model_test.dart new file mode 100644 index 000000000..ac7917df9 --- /dev/null +++ b/packages/apidash_core/test/models/proxy_settings_model_test.dart @@ -0,0 +1,55 @@ +import 'package:test/test.dart'; +import 'package:apidash_core/models/proxy_settings_model.dart'; + +void main() { + const proxySettings = ProxySettings( + host: 'localhost', + port: '8080', + username: 'user', + password: 'pass', + ); + + test('Testing toJson()', () { + const expectedResult = { + 'host': 'localhost', + 'port': '8080', + 'username': 'user', + 'password': 'pass', + }; + expect(proxySettings.toJson(), expectedResult); + }); + + test('Testing fromJson()', () { + const input = { + 'host': 'localhost', + 'port': '8080', + 'username': 'user', + 'password': 'pass', + }; + expect(ProxySettings.fromJson(input), proxySettings); + }); + + test('Testing default values', () { + const defaultSettings = ProxySettings(); + expect(defaultSettings.host, ''); + expect(defaultSettings.port, ''); + expect(defaultSettings.username, null); + expect(defaultSettings.password, null); + }); + + test('Testing equality', () { + const settings1 = ProxySettings( + host: 'localhost', + port: '8080', + username: 'user', + password: 'pass', + ); + const settings2 = ProxySettings( + host: 'localhost', + port: '8080', + username: 'user', + password: 'pass', + ); + expect(settings1, settings2); + }); +} diff --git a/packages/apidash_design_system/lib/widgets/form_generic.dart b/packages/apidash_design_system/lib/widgets/form_generic.dart new file mode 100644 index 000000000..297654190 --- /dev/null +++ b/packages/apidash_design_system/lib/widgets/form_generic.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// Field configuration for the [GenericForm]. +class GenericFormField { + final TextEditingController controller; + final String labelText; + final String? hintText; + final bool obscureText; + final bool required; + final String? Function(String?)? validator; + + GenericFormField({ + required this.controller, + required this.labelText, + this.hintText, + this.obscureText = false, + this.required = false, + this.validator, + }); +} + +/// A generic form that can be used throughout the application. +class GenericForm extends StatelessWidget { + final List fields; + final double fieldSpacing; + + const GenericForm({ + super.key, + required this.fields, + this.fieldSpacing = 8.0, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildFormFields(), + ); + } + + List _buildFormFields() { + final List formFields = []; + + for (int i = 0; i < fields.length; i++) { + final field = fields[i]; + + formFields.add( + TextField( + controller: field.controller, + decoration: InputDecoration( + labelText: field.labelText + (field.required ? ' *' : ''), + hintText: field.hintText, + ), + obscureText: field.obscureText, + ), + ); + + // Add spacing between fields (except after the last one) + if (i < fields.length - 1) { + formFields.add(SizedBox(height: fieldSpacing)); + } + } + + return formFields; + } +} diff --git a/packages/apidash_design_system/lib/widgets/widgets.dart b/packages/apidash_design_system/lib/widgets/widgets.dart index 955babce8..137063e71 100644 --- a/packages/apidash_design_system/lib/widgets/widgets.dart +++ b/packages/apidash_design_system/lib/widgets/widgets.dart @@ -3,6 +3,7 @@ export 'button_icon.dart'; export 'button_text.dart'; export 'checkbox.dart'; export 'dropdown.dart'; +export 'form_generic.dart'; export 'popup_menu.dart'; export 'snackbar.dart'; export 'textfield_outlined.dart'; diff --git a/test/models/settings_model_test.dart b/test/models/settings_model_test.dart index 1928fd0e3..9cbf592aa 100644 --- a/test/models/settings_model_test.dart +++ b/test/models/settings_model_test.dart @@ -5,6 +5,13 @@ import 'package:apidash/models/settings_model.dart'; import 'package:apidash/consts.dart'; void main() { + const proxySettings = ProxySettings( + host: 'localhost', + port: '8080', + username: 'user', + password: 'pass', + ); + const sm = SettingsModel( isDark: false, alwaysShowCollectionPaneScrollbar: true, @@ -18,6 +25,7 @@ void main() { historyRetentionPeriod: HistoryRetentionPeriod.oneWeek, workspaceFolderPath: null, isSSLDisabled: true, + proxySettings: proxySettings, ); test('Testing toJson()', () { @@ -36,6 +44,12 @@ void main() { "historyRetentionPeriod": "oneWeek", "workspaceFolderPath": null, "isSSLDisabled": true, + "proxySettings": { + "host": "localhost", + "port": "8080", + "username": "user", + "password": "pass", + }, }; expect(sm.toJson(), expectedResult); }); @@ -56,6 +70,12 @@ void main() { "historyRetentionPeriod": "oneWeek", "workspaceFolderPath": null, "isSSLDisabled": true, + "proxySettings": { + "host": "localhost", + "port": "8080", + "username": "user", + "password": "pass", + }, }; expect(SettingsModel.fromJson(input), sm); }); @@ -73,12 +93,14 @@ void main() { activeEnvironmentId: null, historyRetentionPeriod: HistoryRetentionPeriod.oneWeek, isSSLDisabled: false, + proxySettings: null, ); expect( sm.copyWith( isDark: true, saveResponses: false, isSSLDisabled: false, + proxySettings: null, ), expectedResult); }); @@ -98,11 +120,31 @@ void main() { "activeEnvironmentId": null, "historyRetentionPeriod": "oneWeek", "workspaceFolderPath": null, - "isSSLDisabled": true + "isSSLDisabled": true, + "proxySettings": { + "host": "localhost", + "port": "8080", + "username": "user", + "password": "pass" + } }'''; expect(sm.toString(), expectedResult); }); + test('Testing proxy settings update', () { + const newProxySettings = ProxySettings( + host: 'proxy.example.com', + port: '3128', + ); + final updatedSettings = sm.copyWith(proxySettings: newProxySettings); + expect(updatedSettings.proxySettings, newProxySettings); + }); + + test('Testing proxy settings disable', () { + final disabledSettings = sm.copyWith(proxySettings: null); + expect(disabledSettings.proxySettings, null); + }); + test('Testing hashcode', () { expect(sm.hashCode, greaterThan(0)); }); diff --git a/test/widgets/dialog_proxy_settings_test.dart b/test/widgets/dialog_proxy_settings_test.dart new file mode 100644 index 000000000..1ff7ce91d --- /dev/null +++ b/test/widgets/dialog_proxy_settings_test.dart @@ -0,0 +1,116 @@ +import 'package:apidash/widgets/dialog_proxy_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +void main() { + testWidgets('ProxySettingsDialog shows correctly', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const ProxySettingsDialog(), + ); + }, + child: const Text('Show Dialog'), + ), + ), + ), + ), + ), + ); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify dialog is shown + expect(find.byType(ProxySettingsDialog), findsOneWidget); + expect(find.text('Proxy Settings'), findsOneWidget); + expect(find.text('Proxy Host'), findsOneWidget); + expect(find.text('Proxy Port'), findsOneWidget); + expect(find.text('Username (Optional)'), findsOneWidget); + expect(find.text('Password (Optional)'), findsOneWidget); + }); + + testWidgets('ProxySettingsDialog saves settings', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const ProxySettingsDialog(), + ); + }, + child: const Text('Show Dialog'), + ), + ), + ), + ), + ), + ); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter proxy settings + await tester.enterText(find.widgetWithText(TextField, 'Proxy Host'), 'localhost'); + await tester.enterText(find.widgetWithText(TextField, 'Proxy Port'), '8080'); + await tester.enterText(find.widgetWithText(TextField, 'Username (Optional)'), 'user'); + await tester.enterText(find.widgetWithText(TextField, 'Password (Optional)'), 'pass'); + + // Save settings + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Verify dialog is closed + expect(find.byType(ProxySettingsDialog), findsNothing); + }); + + testWidgets('ProxySettingsDialog cancels without saving', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const ProxySettingsDialog(), + ); + }, + child: const Text('Show Dialog'), + ), + ), + ), + ), + ), + ); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Enter proxy settings + await tester.enterText(find.widgetWithText(TextField, 'Proxy Host'), 'localhost'); + await tester.enterText(find.widgetWithText(TextField, 'Proxy Port'), '8080'); + + // Cancel without saving + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Verify dialog is closed + expect(find.byType(ProxySettingsDialog), findsNothing); + }); +}