diff --git a/lib/consts.dart b/lib/consts.dart index 4dfc845b..09efd409 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -71,6 +71,10 @@ final kCodeStyle = TextStyle( fontFamilyFallback: kFontFamilyFallback, ); +final kHomeScaffoldKey = GlobalKey(); +final kEnvScaffoldKey = GlobalKey(); +final kHisScaffoldKey = GlobalKey(); + const kHintOpacity = 0.6; const kForegroundOpacity = 0.05; const kOverlayBackgroundOpacity = 0.5; @@ -82,11 +86,12 @@ const kFormDataButtonLabelTextStyle = TextStyle( fontSize: 12, fontWeight: FontWeight.w600, ); -const kTextStylePopupMenuItem = TextStyle(fontSize: 16); +const kTextStylePopupMenuItem = TextStyle(fontSize: 14); final kButtonSidebarStyle = ElevatedButton.styleFrom(padding: kPh12); const kBorderRadius4 = BorderRadius.all(Radius.circular(4)); +const kBorderRadius6 = BorderRadius.all(Radius.circular(6)); const kBorderRadius8 = BorderRadius.all(Radius.circular(8)); final kBorderRadius10 = BorderRadius.circular(10); const kBorderRadius12 = BorderRadius.all(Radius.circular(12)); @@ -99,23 +104,25 @@ const kP6 = EdgeInsets.all(6); const kP8 = EdgeInsets.all(8); const kPs8 = EdgeInsets.only(left: 8); const kPs2 = EdgeInsets.only(left: 2); +const kPe4 = EdgeInsets.only(right: 4); const kPe8 = EdgeInsets.only(right: 8.0); const kPh20v5 = EdgeInsets.symmetric(horizontal: 20, vertical: 5); const kPh20v10 = EdgeInsets.symmetric(horizontal: 20, vertical: 10); const kP10 = EdgeInsets.all(10); -const kPv8 = EdgeInsets.symmetric(vertical: 8); -const kPv6 = EdgeInsets.symmetric(vertical: 6); const kPv2 = EdgeInsets.symmetric(vertical: 2); +const kPv6 = EdgeInsets.symmetric(vertical: 6); +const kPv8 = EdgeInsets.symmetric(vertical: 8); +const kPv10 = EdgeInsets.symmetric(vertical: 10); +const kPv20 = EdgeInsets.symmetric(vertical: 20); const kPh2 = EdgeInsets.symmetric(horizontal: 2); -const kPt24o8 = EdgeInsets.only(top: 24, left: 8.0, right: 8.0, bottom: 8.0); +const kPt28o8 = EdgeInsets.only(top: 28, left: 8.0, right: 8.0, bottom: 8.0); const kPt5o10 = EdgeInsets.only(left: 10.0, right: 10.0, top: 5.0, bottom: 10.0); const kPh4 = EdgeInsets.symmetric(horizontal: 4); const kPh8 = EdgeInsets.symmetric(horizontal: 8); const kPh12 = EdgeInsets.symmetric(horizontal: 12); -const kPh20 = EdgeInsets.symmetric( - horizontal: 20, -); +const kPh20 = EdgeInsets.symmetric(horizontal: 20); +const kPh24 = EdgeInsets.symmetric(horizontal: 24); const kPh20t40 = EdgeInsets.only( left: 20, right: 20, @@ -147,6 +154,9 @@ const kPt8 = EdgeInsets.only( const kPt20 = EdgeInsets.only( top: 20, ); +const kPt24 = EdgeInsets.only( + top: 24, +); const kPt28 = EdgeInsets.only( top: 28, ); @@ -162,6 +172,7 @@ const kPb15 = EdgeInsets.only( const kPb70 = EdgeInsets.only( bottom: 70, ); +const kHSpacer2 = SizedBox(width: 2); const kHSpacer4 = SizedBox(width: 4); const kHSpacer5 = SizedBox(width: 5); const kHSpacer10 = SizedBox(width: 10); @@ -171,6 +182,7 @@ const kHSpacer40 = SizedBox(width: 40); const kVSpacer5 = SizedBox(height: 5); const kVSpacer8 = SizedBox(height: 8); const kVSpacer10 = SizedBox(height: 10); +const kVSpacer16 = SizedBox(height: 16); const kVSpacer20 = SizedBox(height: 20); const kVSpacer40 = SizedBox(height: 40); @@ -185,8 +197,8 @@ const kRandMax = 100000; const kSuggestionsMenuWidth = 300.0; const kSuggestionsMenuMaxHeight = 200.0; -const kReqResTabWidth = 280.0; -const kReqResTabHeight = 32.0; +const kSegmentedTabWidth = 140.0; +const kSegmentedTabHeight = 32.0; const kDataTableScrollbarTheme = ScrollbarThemeData( crossAxisMargin: -4, @@ -310,6 +322,17 @@ final kColorHttpMethodPut = Colors.amber.shade900; final kColorHttpMethodPatch = kColorHttpMethodPut; final kColorHttpMethodDelete = Colors.red.shade800; +enum HistoryRetentionPeriod { + oneWeek("1 Week", Icons.calendar_view_week_rounded), + oneMonth("1 Month", Icons.calendar_view_month_rounded), + threeMonths("3 Months", Icons.calendar_month_rounded), + forever("Forever", Icons.all_inclusive_rounded); + + const HistoryRetentionPeriod(this.label, this.icon); + final String label; + final IconData icon; +} + enum ItemMenuOption { edit("Rename"), delete("Delete"), @@ -718,6 +741,8 @@ const kLabelSave = "Save"; const kLabelDownload = "Download"; const kLabelSaving = "Saving"; const kLabelSaved = "Saved"; +const kLabelCode = "Code"; +const kLabelDuplicate = "Duplicate"; // Request Pane const kLabelRequest = "Request"; const kLabelHideCode = "Hide Code"; diff --git a/lib/main.dart b/lib/main.dart index 5ac878aa..d9730a03 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); GoogleFonts.config.allowRuntimeFetching = false; await openBoxes(); + await autoClearHistory(); if (kIsLinux) { await setupInitialWindow(); } diff --git a/lib/models/history_meta_model.dart b/lib/models/history_meta_model.dart new file mode 100644 index 00000000..9188b6ad --- /dev/null +++ b/lib/models/history_meta_model.dart @@ -0,0 +1,22 @@ +import 'package:apidash/consts.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'history_meta_model.freezed.dart'; + +part 'history_meta_model.g.dart'; + +@freezed +class HistoryMetaModel with _$HistoryMetaModel { + const factory HistoryMetaModel({ + required String historyId, + required String requestId, + @Default("") String name, + required String url, + required HTTPVerb method, + required int responseStatus, + required DateTime timeStamp, + }) = _HistoryMetaModel; + + factory HistoryMetaModel.fromJson(Map json) => + _$HistoryMetaModelFromJson(json); +} diff --git a/lib/models/history_meta_model.freezed.dart b/lib/models/history_meta_model.freezed.dart new file mode 100644 index 00000000..41901461 --- /dev/null +++ b/lib/models/history_meta_model.freezed.dart @@ -0,0 +1,282 @@ +// 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 'history_meta_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'); + +HistoryMetaModel _$HistoryMetaModelFromJson(Map json) { + return _HistoryMetaModel.fromJson(json); +} + +/// @nodoc +mixin _$HistoryMetaModel { + String get historyId => throw _privateConstructorUsedError; + String get requestId => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get url => throw _privateConstructorUsedError; + HTTPVerb get method => throw _privateConstructorUsedError; + int get responseStatus => throw _privateConstructorUsedError; + DateTime get timeStamp => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $HistoryMetaModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $HistoryMetaModelCopyWith<$Res> { + factory $HistoryMetaModelCopyWith( + HistoryMetaModel value, $Res Function(HistoryMetaModel) then) = + _$HistoryMetaModelCopyWithImpl<$Res, HistoryMetaModel>; + @useResult + $Res call( + {String historyId, + String requestId, + String name, + String url, + HTTPVerb method, + int responseStatus, + DateTime timeStamp}); +} + +/// @nodoc +class _$HistoryMetaModelCopyWithImpl<$Res, $Val extends HistoryMetaModel> + implements $HistoryMetaModelCopyWith<$Res> { + _$HistoryMetaModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? historyId = null, + Object? requestId = null, + Object? name = null, + Object? url = null, + Object? method = null, + Object? responseStatus = null, + Object? timeStamp = null, + }) { + return _then(_value.copyWith( + historyId: null == historyId + ? _value.historyId + : historyId // ignore: cast_nullable_to_non_nullable + as String, + requestId: null == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + url: null == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String, + method: null == method + ? _value.method + : method // ignore: cast_nullable_to_non_nullable + as HTTPVerb, + responseStatus: null == responseStatus + ? _value.responseStatus + : responseStatus // ignore: cast_nullable_to_non_nullable + as int, + timeStamp: null == timeStamp + ? _value.timeStamp + : timeStamp // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$HistoryMetaModelImplCopyWith<$Res> + implements $HistoryMetaModelCopyWith<$Res> { + factory _$$HistoryMetaModelImplCopyWith(_$HistoryMetaModelImpl value, + $Res Function(_$HistoryMetaModelImpl) then) = + __$$HistoryMetaModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String historyId, + String requestId, + String name, + String url, + HTTPVerb method, + int responseStatus, + DateTime timeStamp}); +} + +/// @nodoc +class __$$HistoryMetaModelImplCopyWithImpl<$Res> + extends _$HistoryMetaModelCopyWithImpl<$Res, _$HistoryMetaModelImpl> + implements _$$HistoryMetaModelImplCopyWith<$Res> { + __$$HistoryMetaModelImplCopyWithImpl(_$HistoryMetaModelImpl _value, + $Res Function(_$HistoryMetaModelImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? historyId = null, + Object? requestId = null, + Object? name = null, + Object? url = null, + Object? method = null, + Object? responseStatus = null, + Object? timeStamp = null, + }) { + return _then(_$HistoryMetaModelImpl( + historyId: null == historyId + ? _value.historyId + : historyId // ignore: cast_nullable_to_non_nullable + as String, + requestId: null == requestId + ? _value.requestId + : requestId // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + url: null == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String, + method: null == method + ? _value.method + : method // ignore: cast_nullable_to_non_nullable + as HTTPVerb, + responseStatus: null == responseStatus + ? _value.responseStatus + : responseStatus // ignore: cast_nullable_to_non_nullable + as int, + timeStamp: null == timeStamp + ? _value.timeStamp + : timeStamp // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$HistoryMetaModelImpl implements _HistoryMetaModel { + const _$HistoryMetaModelImpl( + {required this.historyId, + required this.requestId, + this.name = "", + required this.url, + required this.method, + required this.responseStatus, + required this.timeStamp}); + + factory _$HistoryMetaModelImpl.fromJson(Map json) => + _$$HistoryMetaModelImplFromJson(json); + + @override + final String historyId; + @override + final String requestId; + @override + @JsonKey() + final String name; + @override + final String url; + @override + final HTTPVerb method; + @override + final int responseStatus; + @override + final DateTime timeStamp; + + @override + String toString() { + return 'HistoryMetaModel(historyId: $historyId, requestId: $requestId, name: $name, url: $url, method: $method, responseStatus: $responseStatus, timeStamp: $timeStamp)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$HistoryMetaModelImpl && + (identical(other.historyId, historyId) || + other.historyId == historyId) && + (identical(other.requestId, requestId) || + other.requestId == requestId) && + (identical(other.name, name) || other.name == name) && + (identical(other.url, url) || other.url == url) && + (identical(other.method, method) || other.method == method) && + (identical(other.responseStatus, responseStatus) || + other.responseStatus == responseStatus) && + (identical(other.timeStamp, timeStamp) || + other.timeStamp == timeStamp)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, historyId, requestId, name, url, + method, responseStatus, timeStamp); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$HistoryMetaModelImplCopyWith<_$HistoryMetaModelImpl> get copyWith => + __$$HistoryMetaModelImplCopyWithImpl<_$HistoryMetaModelImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$HistoryMetaModelImplToJson( + this, + ); + } +} + +abstract class _HistoryMetaModel implements HistoryMetaModel { + const factory _HistoryMetaModel( + {required final String historyId, + required final String requestId, + final String name, + required final String url, + required final HTTPVerb method, + required final int responseStatus, + required final DateTime timeStamp}) = _$HistoryMetaModelImpl; + + factory _HistoryMetaModel.fromJson(Map json) = + _$HistoryMetaModelImpl.fromJson; + + @override + String get historyId; + @override + String get requestId; + @override + String get name; + @override + String get url; + @override + HTTPVerb get method; + @override + int get responseStatus; + @override + DateTime get timeStamp; + @override + @JsonKey(ignore: true) + _$$HistoryMetaModelImplCopyWith<_$HistoryMetaModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/history_meta_model.g.dart b/lib/models/history_meta_model.g.dart new file mode 100644 index 00000000..3d8af833 --- /dev/null +++ b/lib/models/history_meta_model.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'history_meta_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$HistoryMetaModelImpl _$$HistoryMetaModelImplFromJson( + Map json) => + _$HistoryMetaModelImpl( + historyId: json['historyId'] as String, + requestId: json['requestId'] as String, + name: json['name'] as String? ?? "", + url: json['url'] as String, + method: $enumDecode(_$HTTPVerbEnumMap, json['method']), + responseStatus: (json['responseStatus'] as num).toInt(), + timeStamp: DateTime.parse(json['timeStamp'] as String), + ); + +Map _$$HistoryMetaModelImplToJson( + _$HistoryMetaModelImpl instance) => + { + 'historyId': instance.historyId, + 'requestId': instance.requestId, + 'name': instance.name, + 'url': instance.url, + 'method': _$HTTPVerbEnumMap[instance.method]!, + 'responseStatus': instance.responseStatus, + 'timeStamp': instance.timeStamp.toIso8601String(), + }; + +const _$HTTPVerbEnumMap = { + HTTPVerb.get: 'get', + HTTPVerb.head: 'head', + HTTPVerb.post: 'post', + HTTPVerb.put: 'put', + HTTPVerb.patch: 'patch', + HTTPVerb.delete: 'delete', +}; diff --git a/lib/models/history_request_model.dart b/lib/models/history_request_model.dart new file mode 100644 index 00000000..5a9a2b53 --- /dev/null +++ b/lib/models/history_request_model.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'models.dart'; + +part 'history_request_model.freezed.dart'; + +part 'history_request_model.g.dart'; + +@freezed +class HistoryRequestModel with _$HistoryRequestModel { + @JsonSerializable( + explicitToJson: true, + anyMap: true, + ) + const factory HistoryRequestModel({ + required String historyId, + required HistoryMetaModel metaData, + required HttpRequestModel httpRequestModel, + required HttpResponseModel httpResponseModel, + }) = _HistoryRequestModel; + + factory HistoryRequestModel.fromJson(Map json) => + _$HistoryRequestModelFromJson(json); +} diff --git a/lib/models/history_request_model.freezed.dart b/lib/models/history_request_model.freezed.dart new file mode 100644 index 00000000..afdacfdd --- /dev/null +++ b/lib/models/history_request_model.freezed.dart @@ -0,0 +1,258 @@ +// 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 'history_request_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'); + +HistoryRequestModel _$HistoryRequestModelFromJson(Map json) { + return _HistoryRequestModel.fromJson(json); +} + +/// @nodoc +mixin _$HistoryRequestModel { + String get historyId => throw _privateConstructorUsedError; + HistoryMetaModel get metaData => throw _privateConstructorUsedError; + HttpRequestModel get httpRequestModel => throw _privateConstructorUsedError; + HttpResponseModel get httpResponseModel => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $HistoryRequestModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $HistoryRequestModelCopyWith<$Res> { + factory $HistoryRequestModelCopyWith( + HistoryRequestModel value, $Res Function(HistoryRequestModel) then) = + _$HistoryRequestModelCopyWithImpl<$Res, HistoryRequestModel>; + @useResult + $Res call( + {String historyId, + HistoryMetaModel metaData, + HttpRequestModel httpRequestModel, + HttpResponseModel httpResponseModel}); + + $HistoryMetaModelCopyWith<$Res> get metaData; + $HttpRequestModelCopyWith<$Res> get httpRequestModel; + $HttpResponseModelCopyWith<$Res> get httpResponseModel; +} + +/// @nodoc +class _$HistoryRequestModelCopyWithImpl<$Res, $Val extends HistoryRequestModel> + implements $HistoryRequestModelCopyWith<$Res> { + _$HistoryRequestModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? historyId = null, + Object? metaData = null, + Object? httpRequestModel = null, + Object? httpResponseModel = null, + }) { + return _then(_value.copyWith( + historyId: null == historyId + ? _value.historyId + : historyId // ignore: cast_nullable_to_non_nullable + as String, + metaData: null == metaData + ? _value.metaData + : metaData // ignore: cast_nullable_to_non_nullable + as HistoryMetaModel, + httpRequestModel: null == httpRequestModel + ? _value.httpRequestModel + : httpRequestModel // ignore: cast_nullable_to_non_nullable + as HttpRequestModel, + httpResponseModel: null == httpResponseModel + ? _value.httpResponseModel + : httpResponseModel // ignore: cast_nullable_to_non_nullable + as HttpResponseModel, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $HistoryMetaModelCopyWith<$Res> get metaData { + return $HistoryMetaModelCopyWith<$Res>(_value.metaData, (value) { + return _then(_value.copyWith(metaData: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $HttpRequestModelCopyWith<$Res> get httpRequestModel { + return $HttpRequestModelCopyWith<$Res>(_value.httpRequestModel, (value) { + return _then(_value.copyWith(httpRequestModel: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $HttpResponseModelCopyWith<$Res> get httpResponseModel { + return $HttpResponseModelCopyWith<$Res>(_value.httpResponseModel, (value) { + return _then(_value.copyWith(httpResponseModel: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$HistoryRequestModelImplCopyWith<$Res> + implements $HistoryRequestModelCopyWith<$Res> { + factory _$$HistoryRequestModelImplCopyWith(_$HistoryRequestModelImpl value, + $Res Function(_$HistoryRequestModelImpl) then) = + __$$HistoryRequestModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String historyId, + HistoryMetaModel metaData, + HttpRequestModel httpRequestModel, + HttpResponseModel httpResponseModel}); + + @override + $HistoryMetaModelCopyWith<$Res> get metaData; + @override + $HttpRequestModelCopyWith<$Res> get httpRequestModel; + @override + $HttpResponseModelCopyWith<$Res> get httpResponseModel; +} + +/// @nodoc +class __$$HistoryRequestModelImplCopyWithImpl<$Res> + extends _$HistoryRequestModelCopyWithImpl<$Res, _$HistoryRequestModelImpl> + implements _$$HistoryRequestModelImplCopyWith<$Res> { + __$$HistoryRequestModelImplCopyWithImpl(_$HistoryRequestModelImpl _value, + $Res Function(_$HistoryRequestModelImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? historyId = null, + Object? metaData = null, + Object? httpRequestModel = null, + Object? httpResponseModel = null, + }) { + return _then(_$HistoryRequestModelImpl( + historyId: null == historyId + ? _value.historyId + : historyId // ignore: cast_nullable_to_non_nullable + as String, + metaData: null == metaData + ? _value.metaData + : metaData // ignore: cast_nullable_to_non_nullable + as HistoryMetaModel, + httpRequestModel: null == httpRequestModel + ? _value.httpRequestModel + : httpRequestModel // ignore: cast_nullable_to_non_nullable + as HttpRequestModel, + httpResponseModel: null == httpResponseModel + ? _value.httpResponseModel + : httpResponseModel // ignore: cast_nullable_to_non_nullable + as HttpResponseModel, + )); + } +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true, anyMap: true) +class _$HistoryRequestModelImpl implements _HistoryRequestModel { + const _$HistoryRequestModelImpl( + {required this.historyId, + required this.metaData, + required this.httpRequestModel, + required this.httpResponseModel}); + + factory _$HistoryRequestModelImpl.fromJson(Map json) => + _$$HistoryRequestModelImplFromJson(json); + + @override + final String historyId; + @override + final HistoryMetaModel metaData; + @override + final HttpRequestModel httpRequestModel; + @override + final HttpResponseModel httpResponseModel; + + @override + String toString() { + return 'HistoryRequestModel(historyId: $historyId, metaData: $metaData, httpRequestModel: $httpRequestModel, httpResponseModel: $httpResponseModel)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$HistoryRequestModelImpl && + (identical(other.historyId, historyId) || + other.historyId == historyId) && + (identical(other.metaData, metaData) || + other.metaData == metaData) && + (identical(other.httpRequestModel, httpRequestModel) || + other.httpRequestModel == httpRequestModel) && + (identical(other.httpResponseModel, httpResponseModel) || + other.httpResponseModel == httpResponseModel)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, historyId, metaData, httpRequestModel, httpResponseModel); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$HistoryRequestModelImplCopyWith<_$HistoryRequestModelImpl> get copyWith => + __$$HistoryRequestModelImplCopyWithImpl<_$HistoryRequestModelImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$HistoryRequestModelImplToJson( + this, + ); + } +} + +abstract class _HistoryRequestModel implements HistoryRequestModel { + const factory _HistoryRequestModel( + {required final String historyId, + required final HistoryMetaModel metaData, + required final HttpRequestModel httpRequestModel, + required final HttpResponseModel httpResponseModel}) = + _$HistoryRequestModelImpl; + + factory _HistoryRequestModel.fromJson(Map json) = + _$HistoryRequestModelImpl.fromJson; + + @override + String get historyId; + @override + HistoryMetaModel get metaData; + @override + HttpRequestModel get httpRequestModel; + @override + HttpResponseModel get httpResponseModel; + @override + @JsonKey(ignore: true) + _$$HistoryRequestModelImplCopyWith<_$HistoryRequestModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/history_request_model.g.dart b/lib/models/history_request_model.g.dart new file mode 100644 index 00000000..830d7a4c --- /dev/null +++ b/lib/models/history_request_model.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'history_request_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$HistoryRequestModelImpl _$$HistoryRequestModelImplFromJson(Map json) => + _$HistoryRequestModelImpl( + historyId: json['historyId'] as String, + metaData: HistoryMetaModel.fromJson( + Map.from(json['metaData'] as Map)), + httpRequestModel: HttpRequestModel.fromJson( + Map.from(json['httpRequestModel'] as Map)), + httpResponseModel: HttpResponseModel.fromJson( + Map.from(json['httpResponseModel'] as Map)), + ); + +Map _$$HistoryRequestModelImplToJson( + _$HistoryRequestModelImpl instance) => + { + 'historyId': instance.historyId, + 'metaData': instance.metaData.toJson(), + 'httpRequestModel': instance.httpRequestModel.toJson(), + 'httpResponseModel': instance.httpResponseModel.toJson(), + }; diff --git a/lib/models/models.dart b/lib/models/models.dart index 63949949..2471d12c 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,5 +1,7 @@ export 'environment_model.dart'; export 'form_data_model.dart'; +export 'history_meta_model.dart'; +export 'history_request_model.dart'; export 'http_request_model.dart'; export 'http_response_model.dart'; export 'name_value_model.dart'; diff --git a/lib/models/settings_model.dart b/lib/models/settings_model.dart index 846bdf52..e82ffdca 100644 --- a/lib/models/settings_model.dart +++ b/lib/models/settings_model.dart @@ -13,6 +13,7 @@ class SettingsModel { this.saveResponses = true, this.promptBeforeClosing = true, this.activeEnvironmentId, + this.historyRetentionPeriod = HistoryRetentionPeriod.oneWeek, }); final bool isDark; @@ -24,6 +25,7 @@ class SettingsModel { final bool saveResponses; final bool promptBeforeClosing; final String? activeEnvironmentId; + final HistoryRetentionPeriod historyRetentionPeriod; SettingsModel copyWith({ bool? isDark, @@ -35,6 +37,7 @@ class SettingsModel { bool? saveResponses, bool? promptBeforeClosing, String? activeEnvironmentId, + HistoryRetentionPeriod? historyRetentionPeriod, }) { return SettingsModel( isDark: isDark ?? this.isDark, @@ -47,6 +50,8 @@ class SettingsModel { saveResponses: saveResponses ?? this.saveResponses, promptBeforeClosing: promptBeforeClosing ?? this.promptBeforeClosing, activeEnvironmentId: activeEnvironmentId ?? this.activeEnvironmentId, + historyRetentionPeriod: + historyRetentionPeriod ?? this.historyRetentionPeriod, ); } @@ -80,6 +85,18 @@ class SettingsModel { final saveResponses = data["saveResponses"] as bool?; final promptBeforeClosing = data["promptBeforeClosing"] as bool?; final activeEnvironmentId = data["activeEnvironmentId"] as String?; + final historyRetentionPeriodStr = data["historyRetentionPeriod"] as String?; + HistoryRetentionPeriod historyRetentionPeriod = + HistoryRetentionPeriod.oneWeek; + if (historyRetentionPeriodStr != null) { + try { + historyRetentionPeriod = + HistoryRetentionPeriod.values.byName(historyRetentionPeriodStr); + } catch (e) { + // pass + } + } + const sm = SettingsModel(); return sm.copyWith( @@ -92,6 +109,7 @@ class SettingsModel { saveResponses: saveResponses, promptBeforeClosing: promptBeforeClosing, activeEnvironmentId: activeEnvironmentId, + historyRetentionPeriod: historyRetentionPeriod, ); } @@ -108,6 +126,7 @@ class SettingsModel { "saveResponses": saveResponses, "promptBeforeClosing": promptBeforeClosing, "activeEnvironmentId": activeEnvironmentId, + "historyRetentionPeriod": historyRetentionPeriod.name, }; } @@ -129,7 +148,8 @@ class SettingsModel { other.defaultCodeGenLang == defaultCodeGenLang && other.saveResponses == saveResponses && other.promptBeforeClosing == promptBeforeClosing && - other.activeEnvironmentId == activeEnvironmentId; + other.activeEnvironmentId == activeEnvironmentId && + other.historyRetentionPeriod == historyRetentionPeriod; } @override @@ -145,6 +165,7 @@ class SettingsModel { saveResponses, promptBeforeClosing, activeEnvironmentId, + historyRetentionPeriod, ); } } diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index ff68959a..02eead40 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -159,6 +159,32 @@ class CollectionStateNotifier ref.read(hasUnsavedChangesProvider.notifier).state = true; } + void duplicateFromHistory(HistoryRequestModel historyRequestModel) { + final newId = getNewUuid(); + + var itemIds = ref.read(requestSequenceProvider); + var currentModel = historyRequestModel; + final newModel = RequestModel( + id: newId, + name: "${currentModel.metaData.name} (history)", + httpRequestModel: currentModel.httpRequestModel, + responseStatus: currentModel.metaData.responseStatus, + message: kResponseCodeReasons[currentModel.metaData.responseStatus], + httpResponseModel: currentModel.httpResponseModel, + isWorking: false, + sendingTime: null, + ); + + itemIds.insert(0, newId); + var map = {...state!}; + map[newId] = newModel; + state = map; + + ref.read(requestSequenceProvider.notifier).state = [...itemIds]; + ref.read(selectedIdStateProvider.notifier).state = newId; + ref.read(hasUnsavedChangesProvider.notifier).state = true; + } + void update( String id, { HTTPVerb? method, @@ -256,12 +282,29 @@ class CollectionStateNotifier httpResponseModel: responseModel, isWorking: false, ); + String newHistoryId = getNewUuid(); + HistoryRequestModel model = HistoryRequestModel( + historyId: newHistoryId, + metaData: HistoryMetaModel( + historyId: newHistoryId, + requestId: id, + name: requestModel.name, + url: substitutedHttpRequestModel.url, + method: substitutedHttpRequestModel.method, + responseStatus: statusCode, + timeStamp: DateTime.now(), + ), + httpRequestModel: substitutedHttpRequestModel, + httpResponseModel: responseModel, + ); + ref.read(historyMetaStateNotifier.notifier).addHistoryRequest(model); } // update state with response data map = {...state!}; map[id] = newRequestModel; state = map; + ref.read(hasUnsavedChangesProvider.notifier).state = true; } diff --git a/lib/providers/history_providers.dart b/lib/providers/history_providers.dart new file mode 100644 index 00000000..6f5d23df --- /dev/null +++ b/lib/providers/history_providers.dart @@ -0,0 +1,91 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/models/models.dart'; +import '../services/services.dart' show hiveHandler, HiveHandler; +import '../utils/history_utils.dart'; + +final selectedHistoryIdStateProvider = StateProvider((ref) => null); + +final selectedRequestGroupIdStateProvider = StateProvider((ref) { + final selectedHistoryId = ref.watch(selectedHistoryIdStateProvider); + final historyMetaState = ref.read(historyMetaStateNotifier); + if (selectedHistoryId == null) { + return null; + } + return getHistoryRequestKey(historyMetaState![selectedHistoryId]!); +}); + +final selectedHistoryRequestModelProvider = + StateProvider((ref) => null); + +final historySequenceProvider = + StateProvider>?>((ref) { + final historyMetas = ref.watch(historyMetaStateNotifier); + return getTemporalGroups(historyMetas?.values.toList()); +}); + +final StateNotifierProvider?> historyMetaStateNotifier = + StateNotifierProvider((ref) => HistoryMetaStateNotifier(ref, hiveHandler)); + +class HistoryMetaStateNotifier + extends StateNotifier?> { + HistoryMetaStateNotifier(this.ref, this.hiveHandler) : super(null) { + var status = loadHistoryMetas(); + Future.microtask(() { + if (status) { + final temporalGroups = getTemporalGroups(state?.values.toList()); + final latestRequestId = getLatestRequestId(temporalGroups); + if (latestRequestId != null) { + loadHistoryRequest(latestRequestId); + } + } + }); + } + + final Ref ref; + final HiveHandler hiveHandler; + + bool loadHistoryMetas() { + List? historyIds = hiveHandler.getHistoryIds(); + if (historyIds == null || historyIds.isEmpty) { + state = null; + return false; + } else { + Map historyMetaMap = {}; + for (var historyId in historyIds) { + var jsonModel = hiveHandler.getHistoryMeta(historyId); + if (jsonModel != null) { + var jsonMap = Map.from(jsonModel); + var historyMetaModelFromJson = HistoryMetaModel.fromJson(jsonMap); + historyMetaMap[historyId] = historyMetaModelFromJson; + } + } + state = historyMetaMap; + return true; + } + } + + Future loadHistoryRequest(String id) async { + var jsonModel = await hiveHandler.getHistoryRequest(id); + if (jsonModel != null) { + var jsonMap = Map.from(jsonModel); + var historyRequestModelFromJson = HistoryRequestModel.fromJson(jsonMap); + ref.read(selectedHistoryRequestModelProvider.notifier).state = + historyRequestModelFromJson; + ref.read(selectedHistoryIdStateProvider.notifier).state = id; + } + } + + void addHistoryRequest(HistoryRequestModel model) async { + final id = model.historyId; + state = { + ...state ?? {}, + id: model.metaData, + }; + final List updatedHistoryKeys = + state == null ? [id] : [...state!.keys, id]; + hiveHandler.setHistoryIds(updatedHistoryKeys); + hiveHandler.setHistoryMeta(id, model.metaData.toJson()); + await hiveHandler.setHistoryRequest(id, model.toJson()); + } +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 32f808ae..29fc6e59 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -1,4 +1,5 @@ export 'collection_providers.dart'; export 'environment_providers.dart'; +export 'history_providers.dart'; export 'settings_providers.dart'; export 'ui_providers.dart'; diff --git a/lib/providers/settings_providers.dart b/lib/providers/settings_providers.dart index 0fff037b..6293e04b 100644 --- a/lib/providers/settings_providers.dart +++ b/lib/providers/settings_providers.dart @@ -30,6 +30,7 @@ class ThemeStateNotifier extends StateNotifier { bool? saveResponses, bool? promptBeforeClosing, String? activeEnvironmentId, + HistoryRetentionPeriod? historyRetentionPeriod, }) async { state = state.copyWith( isDark: isDark, @@ -41,6 +42,7 @@ class ThemeStateNotifier extends StateNotifier { saveResponses: saveResponses, promptBeforeClosing: promptBeforeClosing, activeEnvironmentId: activeEnvironmentId, + historyRetentionPeriod: historyRetentionPeriod, ); await hiveHandler.saveSettings(state.toJson()); } diff --git a/lib/providers/ui_providers.dart b/lib/providers/ui_providers.dart index 9b6cc3e0..d8076935 100644 --- a/lib/providers/ui_providers.dart +++ b/lib/providers/ui_providers.dart @@ -2,13 +2,14 @@ import 'package:apidash/consts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -final mobileScaffoldKeyStateProvider = StateProvider>( - (ref) => GlobalKey()); +final mobileScaffoldKeyStateProvider = + StateProvider>((ref) => kHomeScaffoldKey); final leftDrawerStateProvider = StateProvider((ref) => false); final navRailIndexStateProvider = StateProvider((ref) => 0); final selectedIdEditStateProvider = StateProvider((ref) => null); final environmentFieldEditStateProvider = StateProvider((ref) => null); final codePaneVisibleStateProvider = StateProvider((ref) => false); +final historyCodePaneVisibleStateProvider = StateProvider((ref) => false); final saveDataStateProvider = StateProvider((ref) => false); final clearDataStateProvider = StateProvider((ref) => false); final hasUnsavedChangesProvider = StateProvider((ref) => false); diff --git a/lib/screens/common_widgets/button_navbar.dart b/lib/screens/common_widgets/button_navbar.dart index 1daf31cd..b9ea8e61 100644 --- a/lib/screens/common_widgets/button_navbar.dart +++ b/lib/screens/common_widgets/button_navbar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; +import 'package:apidash/utils/utils.dart'; class NavbarButton extends ConsumerWidget { const NavbarButton({ @@ -25,24 +26,26 @@ class NavbarButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mobileScaffoldKeyNotifier = + ref.watch(mobileScaffoldKeyStateProvider.notifier); final bool isSelected = railIdx == buttonIdx; final Size size = isCompact ? const Size(56, 32) : const Size(65, 32); + var onPress = isSelected + ? null + : () { + if (buttonIdx != null) { + final scaffoldKey = getScaffoldKey(buttonIdx!); + ref.watch(navRailIndexStateProvider.notifier).state = buttonIdx!; + mobileScaffoldKeyNotifier.state = scaffoldKey; + ref.read(leftDrawerStateProvider.notifier).state = false; + } + onTap?.call(); + }; return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.translucent, - onTap: isSelected - ? null - : () { - if (buttonIdx != null) { - ref.read(navRailIndexStateProvider.notifier).state = - buttonIdx!; - if (railIdx > 1 && buttonIdx! <= 1) { - ref.read(leftDrawerStateProvider.notifier).state = false; - } - } - onTap?.call(); - }, + onTap: onPress, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -56,19 +59,7 @@ class NavbarButton extends ConsumerWidget { : TextButton.styleFrom( fixedSize: size, ), - onPressed: isSelected - ? null - : () { - if (buttonIdx != null) { - ref.read(navRailIndexStateProvider.notifier).state = - buttonIdx!; - if (railIdx > 1 && buttonIdx! <= 1) { - ref.read(leftDrawerStateProvider.notifier).state = - false; - } - } - onTap?.call(); - }, + onPressed: onPress, child: Icon( isSelected ? selectedIcon : icon, color: Theme.of(context).colorScheme.onSurfaceVariant, diff --git a/lib/screens/home_page/editor_pane/details_card/code_pane.dart b/lib/screens/common_widgets/code_pane.dart similarity index 79% rename from lib/screens/home_page/editor_pane/details_card/code_pane.dart rename to lib/screens/common_widgets/code_pane.dart index f61c10a6..1da84542 100644 --- a/lib/screens/home_page/editor_pane/details_card/code_pane.dart +++ b/lib/screens/common_widgets/code_pane.dart @@ -9,14 +9,24 @@ import 'package:apidash/consts.dart'; final Codegen codegen = Codegen(); class CodePane extends ConsumerWidget { - const CodePane({super.key}); + const CodePane({ + super.key, + this.isHistoryRequest = false, + }); + + final bool isHistoryRequest; @override Widget build(BuildContext context, WidgetRef ref) { final CodegenLanguage codegenLanguage = ref.watch(codegenLanguageStateProvider); - final selectedRequestModel = ref.watch(selectedRequestModelProvider); + final selectedHistoryRequestModel = + ref.watch(selectedHistoryRequestModelProvider); + + final selectedRequestModel = isHistoryRequest + ? getRequestModelFromHistoryModel(selectedHistoryRequestModel!) + : ref.watch(selectedRequestModelProvider); final defaultUriScheme = ref.watch(settingsProvider.select((value) => value.defaultUriScheme)); diff --git a/lib/screens/common_widgets/common_widgets.dart b/lib/screens/common_widgets/common_widgets.dart index 6c08887a..02a1f583 100644 --- a/lib/screens/common_widgets/common_widgets.dart +++ b/lib/screens/common_widgets/common_widgets.dart @@ -1,4 +1,5 @@ export 'button_navbar.dart'; +export 'code_pane.dart'; export 'editor_title.dart'; export 'editor_title_actions.dart'; export 'envfield_url.dart'; diff --git a/lib/screens/common_widgets/environment_dropdown.dart b/lib/screens/common_widgets/environment_dropdown.dart index db012b32..cad6a17a 100644 --- a/lib/screens/common_widgets/environment_dropdown.dart +++ b/lib/screens/common_widgets/environment_dropdown.dart @@ -22,8 +22,8 @@ class EnvironmentDropdown extends ConsumerWidget { borderRadius: kBorderRadius8, ), child: EnvironmentPopupMenu( - activeEnvironment: environments?[activeEnvironment], - environments: environmentsList, + value: environments?[activeEnvironment], + items: environmentsList, onChanged: (value) { ref.read(activeEnvironmentIdStateProvider.notifier).state = value?.id; diff --git a/lib/screens/common_widgets/sidebar_header.dart b/lib/screens/common_widgets/sidebar_header.dart index 4e6b16f7..1a26f0ea 100644 --- a/lib/screens/common_widgets/sidebar_header.dart +++ b/lib/screens/common_widgets/sidebar_header.dart @@ -47,7 +47,7 @@ class SidebarHeader extends ConsumerWidget { ? IconButton( style: IconButton.styleFrom( padding: const EdgeInsets.all(4), - minimumSize: const Size(30, 30), + minimumSize: const Size(36, 36), ), onPressed: () { mobileScaffoldKey.currentState?.closeDrawer(); diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index f6c7d0f8..c0f1532f 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -6,6 +6,7 @@ import 'package:apidash/consts.dart'; import 'common_widgets/common_widgets.dart'; import 'envvar/environment_page.dart'; import 'home_page/home_page.dart'; +import 'history/history_page.dart'; import 'settings_page.dart'; class Dashboard extends ConsumerWidget { @@ -14,7 +15,6 @@ class Dashboard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final railIdx = ref.watch(navRailIndexStateProvider); - final mobileScaffoldKey = ref.watch(mobileScaffoldKeyStateProvider); return Scaffold( body: SafeArea( child: Row( @@ -54,18 +54,18 @@ class Dashboard extends ConsumerWidget { style: Theme.of(context).textTheme.labelSmall, ), kVSpacer10, - // IconButton( - // isSelected: railIdx == 2, - // onPressed: () { - // ref.read(navRailIndexStateProvider.notifier).state = 2; - // }, - // icon: const Icon(Icons.history_outlined), - // selectedIcon: const Icon(Icons.history), - // ), - // Text( - // 'History', - // style: Theme.of(context).textTheme.labelSmall, - // ), + IconButton( + isSelected: railIdx == 2, + onPressed: () { + ref.read(navRailIndexStateProvider.notifier).state = 2; + }, + icon: const Icon(Icons.history_outlined), + selectedIcon: const Icon(Icons.history_rounded), + ), + Text( + 'History', + style: Theme.of(context).textTheme.labelSmall, + ), ], ), Expanded( @@ -90,7 +90,7 @@ class Dashboard extends ConsumerWidget { padding: const EdgeInsets.only(bottom: 16.0), child: NavbarButton( railIdx: railIdx, - buttonIdx: 2, + buttonIdx: 3, selectedIcon: Icons.settings, icon: Icons.settings_outlined, label: 'Settings', @@ -112,12 +112,11 @@ class Dashboard extends ConsumerWidget { child: IndexedStack( alignment: AlignmentDirectional.topCenter, index: railIdx, - children: [ - const HomePage(), - EnvironmentPage( - scaffoldKey: mobileScaffoldKey, - ), - const SettingsPage(), + children: const [ + HomePage(), + EnvironmentPage(), + HistoryPage(), + SettingsPage(), ], ), ) diff --git a/lib/screens/envvar/environment_editor.dart b/lib/screens/envvar/environment_editor.dart index 53b17206..934d7ad8 100644 --- a/lib/screens/envvar/environment_editor.dart +++ b/lib/screens/envvar/environment_editor.dart @@ -19,7 +19,7 @@ class EnvironmentEditor extends ConsumerWidget { padding: context.isMediumWindow ? kPb10 : (kIsMacOS || kIsWindows) - ? kPt24o8 + ? kPt28o8 : kP8, child: Column( children: [ @@ -65,34 +65,43 @@ class EnvironmentEditor extends ConsumerWidget { kVSpacer5, Expanded( child: Container( - padding: context.isMediumWindow ? null : kPv6, margin: context.isMediumWindow ? null : kP4, - decoration: context.isMediumWindow - ? null - : BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - width: 1, + child: Card( + margin: EdgeInsets.zero, + color: kColorTransparent, + surfaceTintColor: kColorTransparent, + shape: context.isMediumWindow + ? null + : RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + borderRadius: kBorderRadius12, ), - borderRadius: kBorderRadius12, - ), - child: const Column( - children: [ - kHSpacer40, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + elevation: 0, + child: const Padding( + padding: kPv6, + child: Column( children: [ - SizedBox(width: 30), - Text("Variable"), - SizedBox(width: 30), - Text("Value"), - SizedBox(width: 40), + kHSpacer40, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: 30), + Text("Variable"), + SizedBox(width: 30), + Text("Value"), + SizedBox(width: 40), + ], + ), + kHSpacer40, + Divider(), + Expanded(child: EditEnvironmentVariables()) ], ), - kHSpacer40, - Divider(), - Expanded(child: EditEnvironmentVariables()) - ], + ), ), ), ), diff --git a/lib/screens/envvar/environment_page.dart b/lib/screens/envvar/environment_page.dart index bbf1dcec..360b4551 100644 --- a/lib/screens/envvar/environment_page.dart +++ b/lib/screens/envvar/environment_page.dart @@ -12,10 +12,8 @@ import 'environment_editor.dart'; class EnvironmentPage extends ConsumerWidget { const EnvironmentPage({ super.key, - required this.scaffoldKey, }); - final GlobalKey scaffoldKey; @override Widget build(BuildContext context, WidgetRef ref) { final id = ref.watch(selectedEnvironmentIdStateProvider); @@ -23,7 +21,7 @@ class EnvironmentPage extends ConsumerWidget { selectedEnvironmentModelProvider.select((value) => value?.name))); if (context.isMediumWindow) { return DrawerSplitView( - scaffoldKey: scaffoldKey, + scaffoldKey: kEnvScaffoldKey, mainContent: const EnvironmentEditor(), title: EditorTitle( title: name, diff --git a/lib/screens/envvar/environments_pane.dart b/lib/screens/envvar/environments_pane.dart index 340cc0e8..34d58e4b 100644 --- a/lib/screens/envvar/environments_pane.dart +++ b/lib/screens/envvar/environments_pane.dart @@ -190,8 +190,8 @@ class EnvironmentItem extends ConsumerWidget { ref.read(activeEnvironmentIdStateProvider.notifier).state = id; }, onTap: () { - ref.read(mobileScaffoldKeyStateProvider).currentState?.closeDrawer(); ref.read(selectedEnvironmentIdStateProvider.notifier).state = id; + kEnvScaffoldKey.currentState?.closeDrawer(); }, focusNode: ref.watch(nameTextFieldFocusNodeProvider), onChangedNameEditor: (value) { diff --git a/lib/screens/history/history_details.dart b/lib/screens/history/history_details.dart new file mode 100644 index 00000000..ebdf81c1 --- /dev/null +++ b/lib/screens/history/history_details.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash/screens/common_widgets/common_widgets.dart'; +import 'history_widgets/history_widgets.dart'; + +class HistoryDetails extends StatefulHookConsumerWidget { + const HistoryDetails({super.key}); + + @override + ConsumerState createState() => _HistoryDetailsState(); +} + +class _HistoryDetailsState extends ConsumerState + with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + final selectedHistoryRequest = + ref.watch(selectedHistoryRequestModelProvider); + final metaData = selectedHistoryRequest?.metaData; + + final codePaneVisible = ref.watch(historyCodePaneVisibleStateProvider); + + final TabController controller = + useTabController(initialLength: 3, vsync: this); + + return selectedHistoryRequest != null + ? LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < kMediumWindowWidth; + return Column( + children: [ + kVSpacer5, + Padding( + padding: kPh4, + child: HistoryURLCard( + method: metaData!.method, + url: metaData.url, + )), + kVSpacer10, + if (isCompact) ...[ + SegmentedTabbar( + controller: controller, + tabs: const [ + Tab(text: kLabelRequest), + Tab(text: kLabelResponse), + Tab(text: kLabelCode), + ], + ), + kVSpacer10, + Expanded( + child: TabBarView( + controller: controller, + children: [ + HistoryRequestPane( + isCompact: isCompact, + ), + const HistoryResponsePane(), + const CodePane( + isHistoryRequest: true, + ), + ], + ), + ), + const HistoryPageBottombar() + ] else ...[ + Expanded( + child: Padding( + padding: kPh4, + child: RequestDetailsCard( + child: EqualSplitView( + leftWidget: Column( + children: [ + Expanded( + child: HistoryRequestPane( + isCompact: isCompact, + ), + ), + const HistoryPageBottombar(), + ], + ), + rightWidget: codePaneVisible + ? const CodePane(isHistoryRequest: true) + : const HistoryResponsePane(), + ), + ), + ), + ), + kVSpacer8, + ] + ], + ); + }, + ) + : const SizedBox.shrink(); + } +} diff --git a/lib/screens/history/history_page.dart b/lib/screens/history/history_page.dart new file mode 100644 index 00000000..d56c90f7 --- /dev/null +++ b/lib/screens/history/history_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; +import 'history_pane.dart'; +import 'history_viewer.dart'; + +class HistoryPage extends ConsumerWidget { + const HistoryPage({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyModel = ref.watch(selectedHistoryRequestModelProvider); + final title = historyModel != null + ? getHistoryRequestName(historyModel.metaData) + : 'History'; + if (context.isMediumWindow) { + return DrawerSplitView( + scaffoldKey: kHisScaffoldKey, + mainContent: const HistoryViewer(), + title: Text(title), + leftDrawerContent: const HistoryPane(), + actions: const [SizedBox(width: 16)], + onDrawerChanged: (value) => + ref.read(leftDrawerStateProvider.notifier).state = value, + ); + } + return const Column( + children: [ + Expanded( + child: DashboardSplitView( + sidebarWidget: HistoryPane(), + mainWidget: HistoryViewer(), + ), + ), + ], + ); + } +} diff --git a/lib/screens/history/history_pane.dart b/lib/screens/history/history_pane.dart new file mode 100644 index 00000000..d76eac17 --- /dev/null +++ b/lib/screens/history/history_pane.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; +import 'history_widgets/history_widgets.dart'; + +class HistoryPane extends ConsumerWidget { + const HistoryPane({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: (!context.isMediumWindow && kIsMacOS ? kPt24 : kPt8) + + (context.isMediumWindow ? kPb70 : EdgeInsets.zero), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HistorySidebarHeader(), + Expanded(child: HistoryList()), + kVSpacer5, + ], + ), + ); + } +} + +class HistoryList extends HookConsumerWidget { + const HistoryList({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historySequence = ref.watch(historySequenceProvider); + final alwaysShowHistoryPaneScrollbar = ref.watch(settingsProvider + .select((value) => value.alwaysShowCollectionPaneScrollbar)); + final List? sortedHistoryKeys = historySequence?.keys.toList(); + sortedHistoryKeys?.sort((a, b) => b.compareTo(a)); + ScrollController scrollController = useScrollController(); + return Scrollbar( + controller: scrollController, + thumbVisibility: alwaysShowHistoryPaneScrollbar, + radius: const Radius.circular(12), + child: ListView.separated( + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), + controller: scrollController, + itemCount: sortedHistoryKeys?.length ?? 0, + separatorBuilder: (context, index) => Divider( + height: 0, + thickness: 2, + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + itemBuilder: (context, index) { + var items = historySequence![sortedHistoryKeys![index]]!; + final requestGroups = getRequestGroups(items); + return Padding( + padding: kPv2, + child: HistoryExpansionTile( + date: sortedHistoryKeys[index], + requestGroups: requestGroups, + initiallyExpanded: index == 0, + ), + ); + }, + ), + ); + } +} + +class HistoryExpansionTile extends StatefulHookConsumerWidget { + const HistoryExpansionTile({ + super.key, + required this.requestGroups, + required this.date, + this.initiallyExpanded = false, + }); + + final Map> requestGroups; + final DateTime date; + final bool initiallyExpanded; + + @override + ConsumerState createState() => + _HistoryExpansionTileState(); +} + +class _HistoryExpansionTileState extends ConsumerState + with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) { + final animationController = useAnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + initialValue: widget.initiallyExpanded ? 1.0 : 0.0, + ); + final animation = Tween(begin: 0.0, end: 0.25).animate(animationController); + final colorScheme = Theme.of(context).colorScheme; + final selectedGroupId = ref.watch(selectedRequestGroupIdStateProvider); + return ExpansionTile( + dense: true, + title: Row( + children: [ + RotationTransition( + turns: animation, + child: Icon( + Icons.chevron_right_rounded, + size: 20, + color: colorScheme.onSurface.withOpacity(0.6), + )), + kHSpacer5, + Text( + humanizeDate(widget.date), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + onExpansionChanged: (value) { + if (value) { + animationController.forward(); + } else { + animationController.reverse(); + } + }, + trailing: const SizedBox.shrink(), + tilePadding: kPh8, + shape: const RoundedRectangleBorder(), + collapsedBackgroundColor: colorScheme.surfaceContainerLow, + initiallyExpanded: widget.initiallyExpanded, + childrenPadding: kPv8 + kPe4, + children: widget.requestGroups.values.map((item) { + return Padding( + padding: kPv2 + kPh4, + child: SidebarHistoryCard( + id: item.first.historyId, + models: item, + method: item.first.method, + isSelected: selectedGroupId == getHistoryRequestKey(item.first), + requestGroupSize: item.length, + onTap: () { + ref + .read(historyMetaStateNotifier.notifier) + .loadHistoryRequest(item.first.historyId); + kHisScaffoldKey.currentState?.closeDrawer(); + }, + ), + ); + }).toList(), + ); + } +} diff --git a/lib/screens/history/history_requests.dart b/lib/screens/history/history_requests.dart new file mode 100644 index 00000000..7927cd3c --- /dev/null +++ b/lib/screens/history/history_requests.dart @@ -0,0 +1,161 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/utils/history_utils.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/consts.dart'; + +class HistoryRequests extends ConsumerWidget { + const HistoryRequests({ + super.key, + this.scrollController, + this.onSelect, + }); + + final ScrollController? scrollController; + final Function()? onSelect; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedRequestId = ref.watch(selectedHistoryIdStateProvider); + final selectedRequest = ref.watch(selectedHistoryRequestModelProvider); + final historyMetas = ref.watch(historyMetaStateNotifier); + final requestGroup = getRequestGroup( + historyMetas?.values.toList(), selectedRequest?.metaData); + return ListView( + shrinkWrap: true, + controller: scrollController, + padding: kPh4, + children: [ + kVSpacer10, + ...requestGroup.map((request) => Padding( + padding: kPv2 + kPh4, + child: HistoryRequestCard( + id: request.historyId, + model: request, + isSelected: selectedRequestId == request.historyId, + onTap: () { + ref.read(selectedHistoryIdStateProvider.notifier).state = + request.historyId; + ref + .read(historyMetaStateNotifier.notifier) + .loadHistoryRequest(request.historyId); + onSelect?.call(); + }, + ), + )), + kVSpacer10, + ], + ); + } +} + +class HistorRequestsScrollableSheet extends StatefulWidget { + const HistorRequestsScrollableSheet({ + super.key, + }); + + @override + State createState() => + _HistorRequestsScrollableSheetState(); +} + +class _HistorRequestsScrollableSheetState + extends State { + double sheetPosition = 0.5; + final double dragSensitivity = 600; + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: sheetPosition, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Grabber( + onVerticalDragUpdate: (DragUpdateDetails details) { + setState(() { + sheetPosition -= details.delta.dy / dragSensitivity; + if (sheetPosition < 0.25) { + sheetPosition = 0.25; + } + if (sheetPosition > 0.9) { + sheetPosition = 0.9; + } + }); + }, + isOnDesktopAndWeb: isOnDesktopAndWeb, + ), + Expanded( + child: HistoryRequests( + scrollController: scrollController, + onSelect: () { + Navigator.of(context).pop(); + }, + ), + ), + ], + ); + }); + } + + bool get isOnDesktopAndWeb { + if (kIsWeb) { + return true; + } + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return true; + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + return false; + } + } +} + +class Grabber extends StatelessWidget { + const Grabber({ + super.key, + required this.onVerticalDragUpdate, + required this.isOnDesktopAndWeb, + }); + + final ValueChanged onVerticalDragUpdate; + final bool isOnDesktopAndWeb; + + @override + Widget build(BuildContext context) { + if (!isOnDesktopAndWeb) { + return const SizedBox.shrink(); + } + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onVerticalDragUpdate: onVerticalDragUpdate, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), topRight: Radius.circular(16)), + ), + child: Align( + alignment: Alignment.topCenter, + child: Container( + margin: kPv10, + width: 80.0, + height: 6.0, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8.0), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/history/history_viewer.dart b/lib/screens/history/history_viewer.dart new file mode 100644 index 00000000..4dbb5faf --- /dev/null +++ b/lib/screens/history/history_viewer.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/consts.dart'; +import 'history_details.dart'; +import 'history_requests.dart'; + +class HistoryViewer extends StatelessWidget { + const HistoryViewer({super.key}); + + @override + Widget build(BuildContext context) { + if (context.isMediumWindow) { + return const HistoryDetails(); + } + return Padding( + padding: kIsMacOS || kIsWindows ? kPt28o8 : kP8, + child: Card( + color: kColorTransparent, + surfaceTintColor: kColorTransparent, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + borderRadius: kBorderRadius12, + ), + elevation: 0, + child: const HistorySplitView( + sidebarWidget: HistoryRequests(), + mainWidget: HistoryDetails(), + ), + ), + ); + } +} diff --git a/lib/screens/history/history_widgets/his_action_buttons.dart b/lib/screens/history/history_widgets/his_action_buttons.dart new file mode 100644 index 00000000..436c9557 --- /dev/null +++ b/lib/screens/history/history_widgets/his_action_buttons.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; + +class HistoryActionButtons extends ConsumerWidget { + const HistoryActionButtons({super.key, this.historyRequestModel}); + + final HistoryRequestModel? historyRequestModel; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final collectionStateNotifier = ref.watch(collectionStateNotifierProvider); + final isAvailable = collectionStateNotifier?.values.any((element) => + element.id == historyRequestModel?.metaData.requestId) ?? + false; + final requestId = historyRequestModel?.metaData.requestId; + return FilledButtonGroup(buttons: [ + ButtonData( + icon: Icons.copy_rounded, + label: kLabelDuplicate, + onPressed: requestId != null + ? () { + ref + .read(collectionStateNotifierProvider.notifier) + .duplicateFromHistory(historyRequestModel!); + ref.read(navRailIndexStateProvider.notifier).state = 0; + } + : null, + tooltip: "Duplicate Request", + ), + ButtonData( + icon: Icons.north_east_rounded, + label: kLabelRequest, + onPressed: isAvailable && requestId != null + ? () { + ref.read(selectedIdStateProvider.notifier).state = requestId; + ref.read(navRailIndexStateProvider.notifier).state = 0; + } + : null, + tooltip: isAvailable ? "Go to Request" : "Couldn't find Request", + ), + ]); + } +} diff --git a/lib/screens/history/history_widgets/his_bottombar.dart b/lib/screens/history/history_widgets/his_bottombar.dart new file mode 100644 index 00000000..da6b0ffe --- /dev/null +++ b/lib/screens/history/history_widgets/his_bottombar.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; +import '../history_requests.dart'; +import 'his_action_buttons.dart'; + +class HistoryPageBottombar extends ConsumerWidget { + const HistoryPageBottombar({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedRequestModel = ref.watch(selectedHistoryRequestModelProvider); + final historyMetas = ref.watch(historyMetaStateNotifier); + final requestGroup = getRequestGroup( + historyMetas?.values.toList(), selectedRequestModel?.metaData); + final requestCount = requestGroup.length; + + return Container( + height: 60 + MediaQuery.paddingOf(context).bottom, + width: MediaQuery.sizeOf(context).width, + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + left: 16, + right: 16, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.onInverseSurface, + width: 1, + ), + ), + ), + child: context.isMediumWindow + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + HistoryActionButtons(historyRequestModel: selectedRequestModel), + HistorySheetButton(requestCount: requestCount) + ], + ) + : Center( + child: HistoryActionButtons( + historyRequestModel: selectedRequestModel)), + ); + } +} + +class HistorySheetButton extends StatelessWidget { + const HistorySheetButton({ + super.key, + required this.requestCount, + }); + + final int requestCount; + + @override + Widget build(BuildContext context) { + final isCompact = context.isCompactWindow; + const icon = Icon(Icons.keyboard_arrow_up_rounded); + return Badge( + isLabelVisible: requestCount > 1, + label: Text( + requestCount > 9 ? '9+' : requestCount.toString(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + child: FilledButton.tonal( + style: FilledButton.styleFrom( + minimumSize: const Size(44, 44), + padding: isCompact ? kP4 : const EdgeInsets.fromLTRB(16, 12, 8, 12), + ), + onPressed: requestCount > 1 + ? () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: const HistorRequestsScrollableSheet()); + }, + ); + } + : null, + child: isCompact + ? icon + : const Row( + children: [ + Text( + "Show All", + style: kTextStyleButton, + ), + kHSpacer5, + icon, + ], + ), + ), + ); + } +} diff --git a/lib/screens/history/history_widgets/his_request_pane.dart b/lib/screens/history/history_widgets/his_request_pane.dart new file mode 100644 index 00000000..a2ad35b2 --- /dev/null +++ b/lib/screens/history/history_widgets/his_request_pane.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/consts.dart'; + +class HistoryRequestPane extends ConsumerWidget { + const HistoryRequestPane({ + super.key, + this.isCompact = false, + }); + + final bool isCompact; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedHistoryIdStateProvider); + final codePaneVisible = ref.watch(historyCodePaneVisibleStateProvider); + + final headersMap = ref.watch(selectedHistoryRequestModelProvider + .select((value) => value?.httpRequestModel.headersMap)) ?? + {}; + final headerLength = headersMap.length; + + final paramsMap = ref.watch(selectedHistoryRequestModelProvider + .select((value) => value?.httpRequestModel.paramsMap)) ?? + {}; + final paramLength = paramsMap.length; + + final hasBody = ref.watch(selectedHistoryRequestModelProvider + .select((value) => value?.httpRequestModel.hasBody)) ?? + false; + + return RequestPane( + selectedId: selectedId, + codePaneVisible: codePaneVisible, + onPressedCodeButton: () { + ref.read(historyCodePaneVisibleStateProvider.notifier).state = + !codePaneVisible; + }, + showViewCodeButton: !isCompact, + showIndicators: [ + paramLength > 0, + headerLength > 0, + hasBody, + ], + children: [ + RequestDataTable( + rows: paramsMap, + keyName: kNameURLParam, + ), + RequestDataTable( + rows: headersMap, + keyName: kNameHeader, + ), + const HisRequestBody(), + ], + ); + } +} + +class HisRequestBody extends ConsumerWidget { + const HisRequestBody({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedHistoryModel = ref.watch(selectedHistoryRequestModelProvider); + final requestModel = selectedHistoryModel?.httpRequestModel; + final contentType = requestModel?.bodyContentType; + + return Column( + children: [ + kVSpacer5, + RichText( + text: TextSpan( + style: Theme.of(context).textTheme.labelLarge, + children: [ + const TextSpan( + text: "Content Type: ", + ), + TextSpan( + text: contentType?.name ?? "text", + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + )), + ], + ), + ), + kVSpacer5, + Expanded( + child: switch (contentType) { + ContentType.formdata => Padding( + padding: kPh4, + child: + RequestFormDataTable(rows: requestModel?.formData ?? [])), + // TODO: Fix JsonTextFieldEditor & plug it here + ContentType.json => Padding( + padding: kPt5o10, + child: TextFieldEditor( + key: Key("${selectedHistoryModel?.historyId}-json-body"), + fieldKey: + "${selectedHistoryModel?.historyId}-json-body-viewer", + initialValue: requestModel?.body, + readOnly: true, + ), + ), + _ => Padding( + padding: kPt5o10, + child: TextFieldEditor( + key: Key("${selectedHistoryModel?.historyId}-body"), + fieldKey: "${selectedHistoryModel?.historyId}-body-viewer", + initialValue: requestModel?.body, + readOnly: true, + ), + ), + }, + ) + ], + ); + } +} diff --git a/lib/screens/history/history_widgets/his_response_pane.dart b/lib/screens/history/history_widgets/his_response_pane.dart new file mode 100644 index 00000000..f90390cb --- /dev/null +++ b/lib/screens/history/history_widgets/his_response_pane.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; + +class HistoryResponsePane extends ConsumerWidget { + const HistoryResponsePane({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedHistoryIdStateProvider); + final selectedHistoryRequest = + ref.watch(selectedHistoryRequestModelProvider); + final historyHttpResponseModel = selectedHistoryRequest?.httpResponseModel; + + if (selectedId != null) { + final requestModel = + getRequestModelFromHistoryModel(selectedHistoryRequest!); + return Column( + children: [ + ResponsePaneHeader( + responseStatus: historyHttpResponseModel?.statusCode, + message: kResponseCodeReasons[historyHttpResponseModel?.statusCode], + time: historyHttpResponseModel?.time, + ), + Expanded( + child: ResponseTabView( + selectedId: selectedId, + children: [ + ResponseBody( + selectedRequestModel: requestModel, + ), + ResponseHeaders( + responseHeaders: historyHttpResponseModel?.headers ?? {}, + requestHeaders: + historyHttpResponseModel?.requestHeaders ?? {}, + ), + ], + ), + ), + ], + ); + } + return const Text("No Request Selected"); + } +} diff --git a/lib/screens/history/history_widgets/his_sidebar_header.dart b/lib/screens/history/history_widgets/his_sidebar_header.dart new file mode 100644 index 00000000..6af1e1e8 --- /dev/null +++ b/lib/screens/history/history_widgets/his_sidebar_header.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/consts.dart'; + +class HistorySidebarHeader extends ConsumerWidget { + const HistorySidebarHeader({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mobileScaffoldKey = ref.read(mobileScaffoldKeyStateProvider); + return Padding( + padding: kPe4, + child: Row( + children: [ + kHSpacer10, + Text( + "History", + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + tooltip: "Manage History", + style: IconButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + showHistoryRetentionDialog( + context, + ref.read(settingsProvider.select( + (value) => value.historyRetentionPeriod)), (value) { + ref.read(settingsProvider.notifier).update( + historyRetentionPeriod: value, + ); + }); + }, + icon: const Icon( + Icons.manage_history_rounded, + size: 20, + ), + ), + context.width <= kMinWindowSize.width + ? IconButton( + style: IconButton.styleFrom( + padding: const EdgeInsets.all(4), + minimumSize: const Size(36, 36), + ), + onPressed: () { + mobileScaffoldKey.currentState?.closeDrawer(); + }, + icon: const Icon(Icons.chevron_left), + ) + : const SizedBox.shrink(), + ], + ), + ); + } +} diff --git a/lib/screens/history/history_widgets/his_url_card.dart b/lib/screens/history/history_widgets/his_url_card.dart new file mode 100644 index 00000000..cb8ec0da --- /dev/null +++ b/lib/screens/history/history_widgets/his_url_card.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; + +class HistoryURLCard extends StatelessWidget { + const HistoryURLCard({ + super.key, + required this.method, + required this.url, + }); + + final HTTPVerb method; + final String url; + + @override + Widget build(BuildContext context) { + final fontSize = Theme.of(context).textTheme.titleMedium?.fontSize; + return LayoutBuilder(builder: (context, constraints) { + final isCompact = constraints.maxWidth <= kMinWindowSize.width; + return Card( + color: kColorTransparent, + surfaceTintColor: kColorTransparent, + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + borderRadius: kBorderRadius8, + ), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: 12, + horizontal: isCompact ? 10 : 16, + ), + child: Row( + children: [ + isCompact ? const SizedBox.shrink() : kHSpacer10, + Text( + method.name.toUpperCase(), + style: kCodeStyle.copyWith( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: getHTTPMethodColor( + method, + brightness: Theme.of(context).brightness, + ), + ), + ), + isCompact ? kHSpacer10 : kHSpacer20, + Expanded( + child: ReadOnlyTextField( + initialValue: url, + style: kCodeStyle.copyWith( + fontSize: fontSize, + ), + ), + ) + ], + ), + ), + ); + }); + } +} diff --git a/lib/screens/history/history_widgets/history_widgets.dart b/lib/screens/history/history_widgets/history_widgets.dart new file mode 100644 index 00000000..dc8712ff --- /dev/null +++ b/lib/screens/history/history_widgets/history_widgets.dart @@ -0,0 +1,5 @@ +export 'his_bottombar.dart'; +export 'his_request_pane.dart'; +export 'his_response_pane.dart'; +export 'his_sidebar_header.dart'; +export 'his_url_card.dart'; diff --git a/lib/screens/home_page/collection_pane.dart b/lib/screens/home_page/collection_pane.dart index 521fdad8..b30eded2 100644 --- a/lib/screens/home_page/collection_pane.dart +++ b/lib/screens/home_page/collection_pane.dart @@ -1,7 +1,7 @@ -import 'package:apidash/importer/importer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; +import 'package:apidash/importer/importer.dart'; import 'package:apidash/extensions/extensions.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/models/models.dart'; @@ -220,8 +220,8 @@ class RequestItem extends ConsumerWidget { selectedId: selectedId, editRequestId: editRequestId, onTap: () { - ref.read(mobileScaffoldKeyStateProvider).currentState?.closeDrawer(); ref.read(selectedIdStateProvider.notifier).state = id; + kHomeScaffoldKey.currentState?.closeDrawer(); }, // onDoubleTap: () { // ref.read(selectedIdStateProvider.notifier).state = id; diff --git a/lib/screens/home_page/editor_pane/details_card/details_card.dart b/lib/screens/home_page/editor_pane/details_card/details_card.dart index e14c139c..739f7475 100644 --- a/lib/screens/home_page/editor_pane/details_card/details_card.dart +++ b/lib/screens/home_page/editor_pane/details_card/details_card.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/screens/common_widgets/common_widgets.dart'; import 'request_pane/request_pane.dart'; import 'response_pane.dart'; -import 'code_pane.dart'; class EditorPaneRequestDetailsCard extends ConsumerWidget { const EditorPaneRequestDetailsCard({super.key}); diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart index 5228c660..9b0bb123 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart @@ -124,17 +124,7 @@ class _FormDataBodyState extends ConsumerState { ), DataCell( formRows[index].type == FormDataType.file - ? ElevatedButton.icon( - icon: const Icon( - Icons.snippet_folder_rounded, - size: 20, - ), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(kDataTableRowHeight), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - ), + ? FormDataFileButton( onPressed: () async { var pickedResult = await pickFile(); if (pickedResult != null && @@ -146,14 +136,7 @@ class _FormDataBodyState extends ConsumerState { _onFieldChange(selectedId!); } }, - label: Text( - (formRows[index].type == FormDataType.file && - formRows[index].value.isNotEmpty) - ? formRows[index].value.toString() - : kLabelSelectFile, - overflow: TextOverflow.ellipsis, - style: kFormDataButtonLabelTextStyle, - ), + initialValue: formRows[index].value, ) : CellField( keyId: "$selectedId-$index-form-v-$seed", diff --git a/lib/screens/home_page/editor_pane/editor_request.dart b/lib/screens/home_page/editor_pane/editor_request.dart index 9d01fe2f..52f49a3f 100644 --- a/lib/screens/home_page/editor_pane/editor_request.dart +++ b/lib/screens/home_page/editor_pane/editor_request.dart @@ -27,7 +27,7 @@ class RequestEditor extends StatelessWidget { ), ) : Padding( - padding: kIsMacOS || kIsWindows ? kPt24o8 : kP8, + padding: kIsMacOS || kIsWindows ? kPt28o8 : kP8, child: const Column( children: [ RequestEditorTopBar(), diff --git a/lib/screens/home_page/home_page.dart b/lib/screens/home_page/home_page.dart index a7338a08..76509a08 100644 --- a/lib/screens/home_page/home_page.dart +++ b/lib/screens/home_page/home_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/extensions/extensions.dart'; +import '../mobile/requests_page/requests_page.dart'; import 'editor_pane/editor_pane.dart'; import 'collection_pane.dart'; @@ -8,15 +10,17 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Column( - children: [ - Expanded( - child: DashboardSplitView( - sidebarWidget: CollectionPane(), - mainWidget: RequestEditorPane(), - ), - ), - ], - ); + return context.isMediumWindow + ? const RequestResponsePage() + : const Column( + children: [ + Expanded( + child: DashboardSplitView( + sidebarWidget: CollectionPane(), + mainWidget: RequestEditorPane(), + ), + ), + ], + ); } } diff --git a/lib/screens/mobile/dashboard.dart b/lib/screens/mobile/dashboard.dart index c23c1cd2..0cef40ab 100644 --- a/lib/screens/mobile/dashboard.dart +++ b/lib/screens/mobile/dashboard.dart @@ -4,11 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:apidash/extensions/extensions.dart'; import 'package:apidash/providers/providers.dart'; -import '../settings_page.dart'; -import 'navbar.dart'; import 'requests_page/requests_page.dart'; import '../envvar/environment_page.dart'; +import '../history/history_page.dart'; +import '../settings_page.dart'; import 'widgets/page_base.dart'; +import 'navbar.dart'; class MobileDashboard extends ConsumerStatefulWidget { const MobileDashboard({super.key}); @@ -39,7 +40,7 @@ class _MobileDashboardState extends ConsumerState { ), if (context.isMediumWindow) AnimatedPositioned( - bottom: railIdx > 1 + bottom: railIdx > 2 ? 0 : isLeftDrawerOpen ? 0 @@ -66,27 +67,18 @@ class PageBranch extends ConsumerWidget { final int pageIndex; @override Widget build(BuildContext context, WidgetRef ref) { - final scaffoldKey = ref.watch(mobileScaffoldKeyStateProvider); switch (pageIndex) { case 1: - return EnvironmentPage( - scaffoldKey: scaffoldKey, - ); - // case 2: - // // TODO: Implement history page - // return const PageBase( - // title: 'History', - // scaffoldBody: SizedBox(), - // ); + return const EnvironmentPage(); case 2: + return const HistoryPage(); + case 3: return const PageBase( title: 'Settings', scaffoldBody: SettingsPage(), ); default: - return RequestResponsePage( - scaffoldKey: scaffoldKey, - ); + return const RequestResponsePage(); } } } diff --git a/lib/screens/mobile/navbar.dart b/lib/screens/mobile/navbar.dart index 438969b7..b5402b88 100644 --- a/lib/screens/mobile/navbar.dart +++ b/lib/screens/mobile/navbar.dart @@ -48,19 +48,19 @@ class BottomNavBar extends ConsumerWidget { label: 'Variables', ), ), - // Expanded( - // child: NavbarButton( - // railIdx: railIdx, - // buttonIdx: 2, - // selectedIcon: Icons.history, - // icon: Icons.history_outlined, - // label: 'History', - // ), - // ), Expanded( child: NavbarButton( railIdx: railIdx, buttonIdx: 2, + selectedIcon: Icons.history_rounded, + icon: Icons.history_outlined, + label: 'History', + ), + ), + Expanded( + child: NavbarButton( + railIdx: railIdx, + buttonIdx: 3, selectedIcon: Icons.settings, icon: Icons.settings_outlined, label: 'Settings', diff --git a/lib/screens/mobile/requests_page/request_response_tabs.dart b/lib/screens/mobile/requests_page/request_response_tabs.dart index 38fab16a..34a0ed90 100644 --- a/lib/screens/mobile/requests_page/request_response_tabs.dart +++ b/lib/screens/mobile/requests_page/request_response_tabs.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/consts.dart'; import '../../home_page/editor_pane/details_card/response_pane.dart'; import '../../home_page/editor_pane/editor_request.dart'; @@ -18,84 +19,34 @@ class RequestResponseTabs extends StatelessWidget { child: EditorPaneRequestURLCard(), ), kVSpacer10, - RequestResponseTabbar( + SegmentedTabbar( controller: controller, + tabs: const [ + Tab(text: kLabelRequest), + Tab(text: kLabelResponse), + ], ), - Expanded( - child: RequestResponseTabviews( - controller: controller, - )) + Expanded(child: RequestResponseTabviews(controller: controller)) ], ); } } -class RequestResponseTabbar extends StatelessWidget { - const RequestResponseTabbar({ - super.key, - required this.controller, - }); - - final TabController controller; - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: kReqResTabWidth, - height: kReqResTabHeight, - decoration: BoxDecoration( - borderRadius: kBorderRadius20, - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - child: ClipRRect( - borderRadius: kBorderRadius20, - child: TabBar( - dividerColor: Colors.transparent, - indicatorWeight: 0.0, - indicatorSize: TabBarIndicatorSize.tab, - unselectedLabelColor: - Theme.of(context).colorScheme.onSurface.withOpacity(0.4), - labelStyle: kTextStyleTab.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - unselectedLabelStyle: kTextStyleTab, - splashBorderRadius: kBorderRadius20, - indicator: BoxDecoration( - borderRadius: kBorderRadius20, - color: Theme.of(context).colorScheme.primary, - ), - controller: controller, - tabs: const [ - Tab( - text: kLabelRequest, - ), - Tab( - text: kLabelResponse, - ), - ], - ), - ), - ), - ); - } -} - class RequestResponseTabviews extends StatelessWidget { const RequestResponseTabviews({super.key, required this.controller}); final TabController controller; @override Widget build(BuildContext context) { - return TabBarView(controller: controller, children: const [ - RequestEditor(), - Padding( - padding: kPt8, - child: ResponsePane(), - ), - ]); + return TabBarView( + controller: controller, + children: const [ + RequestEditor(), + Padding( + padding: kPt8, + child: ResponsePane(), + ), + ], + ); } } diff --git a/lib/screens/mobile/requests_page/requests_page.dart b/lib/screens/mobile/requests_page/requests_page.dart index 86d4d70d..fcd291b1 100644 --- a/lib/screens/mobile/requests_page/requests_page.dart +++ b/lib/screens/mobile/requests_page/requests_page.dart @@ -7,7 +7,6 @@ import 'package:apidash/widgets/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../home_page/collection_pane.dart'; import '../../home_page/editor_pane/url_card.dart'; -import '../../home_page/editor_pane/details_card/code_pane.dart'; import '../../home_page/editor_pane/editor_default.dart'; import '../../common_widgets/common_widgets.dart'; import '../widgets/page_base.dart'; @@ -16,11 +15,8 @@ import 'request_response_tabs.dart'; class RequestResponsePage extends StatefulHookConsumerWidget { const RequestResponsePage({ super.key, - required this.scaffoldKey, }); - final GlobalKey scaffoldKey; - @override ConsumerState createState() => _RequestResponsePageState(); @@ -36,7 +32,7 @@ class _RequestResponsePageState extends ConsumerState final TabController requestResponseTabController = useTabController(initialLength: 2, vsync: this); return DrawerSplitView( - scaffoldKey: widget.scaffoldKey, + scaffoldKey: kHomeScaffoldKey, title: EditorTitle( title: name, onSelected: (ItemMenuOption item) { diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index c7426524..2ce155f2 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -81,27 +81,6 @@ class SettingsPage extends ConsumerWidget { }, items: kSupportedUriSchemes, ), - // DropdownButtonHideUnderline( - // child: DropdownButton( - // borderRadius: kBorderRadius8, - // onChanged: (value) { - // ref - // .read(settingsProvider.notifier) - // .update(defaultUriScheme: value); - // }, - // value: settings.defaultUriScheme, - // items: kSupportedUriSchemes - // .map>((String value) { - // return DropdownMenuItem( - // value: value, - // child: Padding( - // padding: kP10, - // child: Text(value), - // ), - // ); - // }).toList(), - // ), - // ), ), ), ListTile( @@ -123,26 +102,6 @@ class SettingsPage extends ConsumerWidget { }, items: CodegenLanguage.values, ), - // DropdownButtonHideUnderline( - // child: DropdownButton( - // borderRadius: kBorderRadius8, - // value: settings.defaultCodeGenLang, - // onChanged: (value) { - // ref - // .read(settingsProvider.notifier) - // .update(defaultCodeGenLang: value); - // }, - // items: CodegenLanguage.values.map((value) { - // return DropdownMenuItem( - // value: value, - // child: Padding( - // padding: kP10, - // child: Text(value.label), - // ), - // ); - // }).toList(), - // ), - // ), ), ), CheckboxListTile( @@ -186,6 +145,29 @@ class SettingsPage extends ConsumerWidget { ), ), ), + ListTile( + hoverColor: kColorTransparent, + title: const Text('History Retention Period'), + subtitle: Text( + 'Your request history will be retained${settings.historyRetentionPeriod == HistoryRetentionPeriod.forever ? "" : " for"} ${settings.historyRetentionPeriod.label}'), + trailing: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.onSurface, + ), + borderRadius: kBorderRadius8, + ), + child: HistoryRetentionPopupMenu( + value: settings.historyRetentionPeriod, + onChanged: (value) { + ref + .read(settingsProvider.notifier) + .update(historyRetentionPeriod: value); + }, + items: HistoryRetentionPeriod.values, + ), + ), + ), ListTile( hoverColor: kColorTransparent, title: const Text('Clear Data'), diff --git a/lib/services/history_service.dart b/lib/services/history_service.dart new file mode 100644 index 00000000..d34d17fb --- /dev/null +++ b/lib/services/history_service.dart @@ -0,0 +1,51 @@ +import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; +import 'hive_services.dart'; + +Future autoClearHistory() async { + final settingsMap = hiveHandler.settings; + final retentionPeriod = settingsMap['historyRetentionPeriod']; + + HistoryRetentionPeriod historyRetentionPeriod = + HistoryRetentionPeriod.oneWeek; + if (retentionPeriod != null) { + historyRetentionPeriod = + HistoryRetentionPeriod.values.byName(retentionPeriod); + } + DateTime? retentionDate = getRetentionDate(historyRetentionPeriod); + + if (retentionDate == null) { + return; + } else { + List? historyIds = hiveHandler.getHistoryIds(); + List toRemoveIds = []; + + if (historyIds == null || historyIds.isEmpty) { + return; + } + + for (var historyId in historyIds) { + var jsonModel = hiveHandler.getHistoryMeta(historyId); + if (jsonModel != null) { + var jsonMap = Map.from(jsonModel); + HistoryMetaModel historyMetaModelFromJson = + HistoryMetaModel.fromJson(jsonMap); + if (historyMetaModelFromJson.timeStamp.isBefore(retentionDate)) { + toRemoveIds.add(historyId); + } + } + } + + if (toRemoveIds.isEmpty) { + return; + } + + for (var id in toRemoveIds) { + await hiveHandler.deleteHistoryRequest(id); + hiveHandler.deleteHistoryMeta(id); + } + hiveHandler.setHistoryIds( + historyIds..removeWhere((id) => toRemoveIds.contains(id))); + } +} diff --git a/lib/services/hive_services.dart b/lib/services/hive_services.dart index 0b5a86fc..1a0f7ac0 100644 --- a/lib/services/hive_services.dart +++ b/lib/services/hive_services.dart @@ -3,9 +3,14 @@ import 'package:hive_flutter/hive_flutter.dart'; const String kDataBox = "apidash-data"; const String kKeyDataBoxIds = "ids"; + const String kEnvironmentBox = "apidash-environments"; const String kKeyEnvironmentBoxIds = "environmentIds"; +const String kHistoryMetaBox = "apidash-history-meta"; +const String kHistoryBoxIds = "historyIds"; +const String kHistoryLazyBox = "apidash-history-lazy"; + const String kSettingsBox = "apidash-settings"; Future openBoxes() async { @@ -13,6 +18,8 @@ Future openBoxes() async { await Hive.openBox(kDataBox); await Hive.openBox(kSettingsBox); await Hive.openBox(kEnvironmentBox); + await Hive.openBox(kHistoryMetaBox); + await Hive.openLazyBox(kHistoryLazyBox); } (Size?, Offset?) getInitialSize() { @@ -38,11 +45,15 @@ class HiveHandler { late final Box dataBox; late final Box settingsBox; late final Box environmentBox; + late final Box historyMetaBox; + late final LazyBox historyLazyBox; HiveHandler() { dataBox = Hive.box(kDataBox); settingsBox = Hive.box(kSettingsBox); environmentBox = Hive.box(kEnvironmentBox); + historyMetaBox = Hive.box(kHistoryMetaBox); + historyLazyBox = Hive.lazyBox(kHistoryLazyBox); } Map get settings => settingsBox.toMap(); @@ -69,6 +80,25 @@ class HiveHandler { Future deleteEnvironment(String id) => environmentBox.delete(id); + dynamic getHistoryIds() => historyMetaBox.get(kHistoryBoxIds); + Future setHistoryIds(List? ids) => + historyMetaBox.put(kHistoryBoxIds, ids); + + dynamic getHistoryMeta(String id) => historyMetaBox.get(id); + Future setHistoryMeta( + String id, Map? historyMetaJson) => + historyMetaBox.put(id, historyMetaJson); + + Future deleteHistoryMeta(String id) => historyMetaBox.delete(id); + + Future getHistoryRequest(String id) async => + await historyLazyBox.get(id); + Future setHistoryRequest( + String id, Map? historyRequestJsoon) => + historyLazyBox.put(id, historyRequestJsoon); + + Future deleteHistoryRequest(String id) => historyLazyBox.delete(id); + Future clear() async { await dataBox.clear(); await environmentBox.clear(); diff --git a/lib/services/services.dart b/lib/services/services.dart index a7cf03fd..7551de9b 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -1,3 +1,4 @@ export 'http_service.dart'; export 'hive_services.dart'; +export 'history_service.dart'; export 'window_services.dart'; diff --git a/lib/utils/convert_utils.dart b/lib/utils/convert_utils.dart index e855568b..c772134e 100644 --- a/lib/utils/convert_utils.dart +++ b/lib/utils/convert_utils.dart @@ -1,10 +1,25 @@ import 'dart:typed_data'; import 'dart:convert'; import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; import '../models/models.dart'; import '../consts.dart'; import 'package:http/http.dart' as http; +String humanizeDate(DateTime? date) { + if (date == null) { + return ""; + } + return DateFormat('MMMM d, yyyy').format(date); +} + +String humanizeTime(DateTime? time) { + if (time == null) { + return ""; + } + return DateFormat('hh:mm:ss a').format(time); +} + String humanizeDuration(Duration? duration) { if (duration == null) { return ""; diff --git a/lib/utils/history_utils.dart b/lib/utils/history_utils.dart new file mode 100644 index 00000000..cce87998 --- /dev/null +++ b/lib/utils/history_utils.dart @@ -0,0 +1,131 @@ +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; +import 'convert_utils.dart'; + +DateTime stripTime(DateTime dateTime) { + return DateTime(dateTime.year, dateTime.month, dateTime.day); +} + +RequestModel getRequestModelFromHistoryModel(HistoryRequestModel model) { + return RequestModel( + id: model.historyId, + name: model.metaData.name, + responseStatus: model.httpResponseModel.statusCode, + message: kResponseCodeReasons[model.httpResponseModel.statusCode], + httpRequestModel: model.httpRequestModel, + httpResponseModel: model.httpResponseModel, + ); +} + +String getHistoryRequestName(HistoryMetaModel model) { + if (model.name.isNotEmpty) { + return model.name; + } else { + return model.url; + } +} + +String getHistoryRequestKey(HistoryMetaModel model) { + String timeStamp = humanizeDate(model.timeStamp); + if (model.name.isNotEmpty) { + return model.name + model.method.name + timeStamp; + } else { + return model.url + model.method.name + timeStamp; + } +} + +String? getLatestRequestId( + Map> temporalGroups) { + if (temporalGroups.isEmpty) { + return null; + } + List keys = temporalGroups.keys.toList(); + keys.sort((a, b) => b.compareTo(a)); + return temporalGroups[keys.first]!.first.historyId; +} + +DateTime getDateTimeKey(List keys, DateTime currentKey) { + if (keys.isEmpty) return currentKey; + for (DateTime key in keys) { + if (key.year == currentKey.year && + key.month == currentKey.month && + key.day == currentKey.day) { + return key; + } + } + return stripTime(currentKey); +} + +Map> getTemporalGroups( + List? models) { + Map> temporalGroups = {}; + if (models?.isEmpty ?? true) { + return temporalGroups; + } + for (HistoryMetaModel model in models!) { + List existingKeys = temporalGroups.keys.toList(); + DateTime key = getDateTimeKey(existingKeys, model.timeStamp); + if (existingKeys.contains(key)) { + temporalGroups[key]!.add(model); + } else { + temporalGroups[stripTime(key)] = [model]; + } + } + temporalGroups.forEach((key, value) { + value.sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); + }); + return temporalGroups; +} + +Map> getRequestGroups( + List? models) { + Map> historyGroups = {}; + if (models?.isEmpty ?? true) { + return historyGroups; + } + for (HistoryMetaModel model in models!) { + String key = getHistoryRequestKey(model); + if (historyGroups.containsKey(key)) { + historyGroups[key]!.add(model); + } else { + historyGroups[key] = [model]; + } + } + historyGroups.forEach((key, value) { + value.sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); + }); + return historyGroups; +} + +List getRequestGroup( + List? models, HistoryMetaModel? selectedModel) { + List requestGroup = []; + if (selectedModel == null || (models?.isEmpty ?? true)) { + return requestGroup; + } + String selectedModelKey = getHistoryRequestKey(selectedModel); + for (HistoryMetaModel model in models!) { + String key = getHistoryRequestKey(model); + if (key == selectedModelKey) { + requestGroup.add(model); + } + } + requestGroup.sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); + return requestGroup; +} + +DateTime? getRetentionDate(HistoryRetentionPeriod retentionPeriod) { + DateTime now = DateTime.now(); + DateTime today = stripTime(now); + + switch (retentionPeriod) { + case HistoryRetentionPeriod.oneWeek: + return today.subtract(const Duration(days: 7)); + case HistoryRetentionPeriod.oneMonth: + return today.subtract(const Duration(days: 30)); + case HistoryRetentionPeriod.threeMonths: + return today.subtract(const Duration(days: 90)); + default: + return null; + } +} diff --git a/lib/utils/ui_utils.dart b/lib/utils/ui_utils.dart index 3bf1fb98..764c821b 100644 --- a/lib/utils/ui_utils.dart +++ b/lib/utils/ui_utils.dart @@ -69,3 +69,14 @@ double? getJsonPreviewerMaxRootNodeWidth(double w) { } return w - 150; } + +GlobalKey getScaffoldKey(int railIdx) { + switch (railIdx) { + case 1: + return kEnvScaffoldKey; + case 2: + return kHisScaffoldKey; + default: + return kHomeScaffoldKey; + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 1857825d..d38e34b8 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,6 +1,7 @@ export 'ui_utils.dart'; export 'convert_utils.dart'; export 'header_utils.dart'; +export 'history_utils.dart'; export 'http_utils.dart'; export 'file_utils.dart'; export 'window_utils.dart'; diff --git a/lib/widgets/button_form_data_file.dart b/lib/widgets/button_form_data_file.dart new file mode 100644 index 00000000..262ea76b --- /dev/null +++ b/lib/widgets/button_form_data_file.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; + +class FormDataFileButton extends StatelessWidget { + const FormDataFileButton({super.key, this.onPressed, this.initialValue}); + + final VoidCallback? onPressed; + final String? initialValue; + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + icon: const Icon( + Icons.snippet_folder_rounded, + size: 20, + ), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(kDataTableRowHeight), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + onPressed: onPressed, + label: Text( + initialValue ?? kLabelSelectFile, + overflow: TextOverflow.ellipsis, + style: kFormDataButtonLabelTextStyle, + ), + ); + } +} diff --git a/lib/widgets/button_group_filled.dart b/lib/widgets/button_group_filled.dart new file mode 100644 index 00000000..7e1de0db --- /dev/null +++ b/lib/widgets/button_group_filled.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; + +class ButtonData { + ButtonData({ + required this.label, + required this.icon, + this.onPressed, + this.tooltip = "", + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + final String tooltip; +} + +class FilledButtonGroup extends StatelessWidget { + const FilledButtonGroup({super.key, required this.buttons}); + + final List buttons; + + Widget buildButton(ButtonData buttonData, {bool showLabel = true}) { + final icon = Icon(buttonData.icon, size: 20); + final label = Text( + buttonData.label, + style: kTextStyleButton, + ); + return Tooltip( + message: buttonData.tooltip, + child: FilledButton.icon( + style: FilledButton.styleFrom( + minimumSize: const Size(44, 44), + padding: kPh12, + shape: const ContinuousRectangleBorder()), + onPressed: buttonData.onPressed, + label: showLabel + ? Row( + children: [ + icon, + kHSpacer4, + label, + ], + ) + : icon, + ), + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final showLabel = constraints.maxWidth > buttons.length * 110; + List buttonWidgets = buttons + .map((button) => buildButton(button, showLabel: showLabel)) + .toList(); + + List buttonsWithSpacers = []; + for (int i = 0; i < buttonWidgets.length; i++) { + buttonsWithSpacers.add(buttonWidgets[i]); + if (i < buttonWidgets.length - 1) { + buttonsWithSpacers.add(kHSpacer2); + } + } + return ClipRRect( + borderRadius: kBorderRadius20, + child: Row( + mainAxisSize: MainAxisSize.min, + children: buttonsWithSpacers, + ), + ); + }); + } +} diff --git a/lib/widgets/card_history_request.dart b/lib/widgets/card_history_request.dart new file mode 100644 index 00000000..228cbf5b --- /dev/null +++ b/lib/widgets/card_history_request.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; +import 'texts.dart'; + +class HistoryRequestCard extends StatelessWidget { + const HistoryRequestCard({ + super.key, + required this.id, + required this.model, + this.isSelected = false, + this.onTap, + }); + + final String id; + final HistoryMetaModel model; + final bool isSelected; + final Function()? onTap; + + @override + Widget build(BuildContext context) { + final Color color = Theme.of(context).colorScheme.surface; + final Color colorVariant = + Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.5); + final Color surfaceTint = Theme.of(context).colorScheme.primary; + return Card( + shape: const ContinuousRectangleBorder(borderRadius: kBorderRadius12), + elevation: isSelected ? 1 : 0, + surfaceTintColor: isSelected ? surfaceTint : null, + color: isSelected + ? Theme.of(context).colorScheme.brightness == Brightness.dark + ? colorVariant + : color + : color, + margin: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: kBorderRadius6, + hoverColor: colorVariant, + focusColor: colorVariant.withOpacity(0.5), + child: Padding( + padding: kPv6 + kPh8, + child: SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: Text( + humanizeTime(model.timeStamp), + softWrap: false, + overflow: TextOverflow.fade, + style: kCodeStyle, + ), + ), + kHSpacer4, + StatusCode(statusCode: model.responseStatus), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/card_sidebar_history.dart b/lib/widgets/card_sidebar_history.dart new file mode 100644 index 00000000..9b1a9f20 --- /dev/null +++ b/lib/widgets/card_sidebar_history.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; +import 'texts.dart' show MethodBox; + +class SidebarHistoryCard extends StatelessWidget { + const SidebarHistoryCard({ + super.key, + required this.id, + required this.models, + required this.method, + this.isSelected = false, + this.requestGroupSize = 1, + this.onTap, + }); + + final String id; + final List models; + final HTTPVerb method; + final bool isSelected; + final int requestGroupSize; + final Function()? onTap; + + @override + Widget build(BuildContext context) { + final Color color = Theme.of(context).colorScheme.surface; + final Color colorVariant = + Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.5); + final model = models.first; + final Color surfaceTint = Theme.of(context).colorScheme.primary; + final String name = getHistoryRequestName(model); + return Tooltip( + message: name, + triggerMode: TooltipTriggerMode.manual, + waitDuration: const Duration(seconds: 1), + child: Card( + shape: const RoundedRectangleBorder( + borderRadius: kBorderRadius8, + ), + elevation: isSelected ? 1 : 0, + surfaceTintColor: isSelected ? surfaceTint : null, + color: isSelected + ? Theme.of(context).colorScheme.brightness == Brightness.dark + ? colorVariant + : color + : color, + margin: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: kBorderRadius8, + hoverColor: colorVariant, + focusColor: colorVariant.withOpacity(0.5), + child: Padding( + padding: const EdgeInsets.only( + left: 6, + right: 6, + top: 5, + bottom: 5, + ), + child: SizedBox( + height: 20, + child: Row( + children: [ + MethodBox(method: method), + kHSpacer4, + Expanded( + child: Text( + name, + softWrap: false, + overflow: TextOverflow.fade, + ), + ), + requestGroupSize > 1 ? kHSpacer4 : const SizedBox.shrink(), + Visibility( + visible: requestGroupSize > 1, + child: Container( + padding: kPh4, + constraints: const BoxConstraints(minWidth: 24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: kBorderRadius6, + ), + child: Center( + child: Text( + requestGroupSize > 9 + ? "9+" + : requestGroupSize.toString(), + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + )), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialog_history_retention.dart b/lib/widgets/dialog_history_retention.dart new file mode 100644 index 00000000..67f513ac --- /dev/null +++ b/lib/widgets/dialog_history_retention.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; + +showHistoryRetentionDialog( + BuildContext context, + HistoryRetentionPeriod historyRetentionPeriod, + Function(HistoryRetentionPeriod) onRetentionPeriodChange, +) { + HistoryRetentionPeriod selectedRetentionPeriod = historyRetentionPeriod; + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + icon: const Icon(Icons.manage_history_rounded), + iconColor: Theme.of(context).colorScheme.primary, + title: const Text("Manage History"), + titleTextStyle: Theme.of(context).textTheme.titleLarge, + contentPadding: kPv20, + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: kPh24, + child: Text( + "Select the duration for which you want to retain your request history", + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.8), + ), + ), + ), + kVSpacer10, + ...HistoryRetentionPeriod.values + .map((e) => RadioListTile( + title: Text( + e.label, + style: TextStyle( + color: selectedRetentionPeriod == e + ? Theme.of(context).colorScheme.primary + : null), + ), + secondary: Icon(e.icon, + color: selectedRetentionPeriod == e + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6)), + value: e, + groupValue: selectedRetentionPeriod, + onChanged: (value) { + if (value != null) { + selectedRetentionPeriod = value; + (context as Element).markNeedsBuild(); + } + }, + )) + ], + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: const Text('Confirm'), + onPressed: () { + onRetentionPeriodChange(selectedRetentionPeriod); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); +} diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index 736ffd83..5bdd2f82 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -9,11 +9,13 @@ class TextFieldEditor extends StatefulWidget { required this.fieldKey, this.onChanged, this.initialValue, + this.readOnly = false, }); final String fieldKey; final Function(String)? onChanged; final String? initialValue; + final bool readOnly; @override State createState() => _TextFieldEditorState(); } @@ -69,6 +71,7 @@ class _TextFieldEditorState extends State { keyboardType: TextInputType.multiline, expands: true, maxLines: null, + readOnly: widget.readOnly, style: kCodeStyle, textAlignVertical: TextAlignVertical.top, onChanged: widget.onChanged, diff --git a/lib/widgets/error_message.dart b/lib/widgets/error_message.dart index b6e9cf46..ed8a2cb8 100644 --- a/lib/widgets/error_message.dart +++ b/lib/widgets/error_message.dart @@ -20,38 +20,40 @@ class ErrorMessage extends StatelessWidget { return Padding( padding: kPh20v10, child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - showIcon - ? Icon( - Icons.warning_rounded, - size: 40, - color: color, - ) - : const SizedBox(), - SelectableText( - message ?? 'An error occurred. $kUnexpectedRaiseIssue', - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: color), - ), - kVSpacer20, - showIssueButton - ? FilledButton.tonalIcon( - onPressed: () { - launchUrl(Uri.parse(kGitUrl)); - }, - icon: const Icon(Icons.arrow_outward_rounded), - label: Text( - 'Raise Issue', - style: Theme.of(context).textTheme.titleMedium, - ), - ) - : const SizedBox(), - ], + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + showIcon + ? Icon( + Icons.warning_rounded, + size: 40, + color: color, + ) + : const SizedBox(), + SelectableText( + message ?? 'An error occurred. $kUnexpectedRaiseIssue', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: color), + ), + kVSpacer20, + showIssueButton + ? FilledButton.tonalIcon( + onPressed: () { + launchUrl(Uri.parse(kGitUrl)); + }, + icon: const Icon(Icons.arrow_outward_rounded), + label: Text( + 'Raise Issue', + style: Theme.of(context).textTheme.titleMedium, + ), + ) + : const SizedBox(), + ], + ), ), ), ); diff --git a/lib/widgets/field_raw.dart b/lib/widgets/field_raw.dart index 8adf6fd0..1f216b74 100644 --- a/lib/widgets/field_raw.dart +++ b/lib/widgets/field_raw.dart @@ -8,16 +8,19 @@ class RawTextField extends StatelessWidget { this.controller, this.hintText, this.style, + this.readOnly = false, }); final void Function(String)? onChanged; final TextEditingController? controller; final String? hintText; final TextStyle? style; + final bool readOnly; @override Widget build(BuildContext context) { return TextField( + readOnly: readOnly, controller: controller, onChanged: onChanged, style: style, diff --git a/lib/widgets/field_read_only.dart b/lib/widgets/field_read_only.dart new file mode 100644 index 00000000..abdb0b4e --- /dev/null +++ b/lib/widgets/field_read_only.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; + +class ReadOnlyTextField extends StatelessWidget { + const ReadOnlyTextField({ + super.key, + this.initialValue, + this.style, + this.decoration, + }); + + final String? initialValue; + final TextStyle? style; + final InputDecoration? decoration; + + @override + Widget build(BuildContext context) { + return TextField( + readOnly: true, + controller: TextEditingController(text: initialValue), + style: style, + decoration: decoration ?? + const InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: kPv8, + ), + ); + } +} diff --git a/lib/widgets/popup_menu_codegen.dart b/lib/widgets/popup_menu_codegen.dart index 6bb164f6..421d1ce7 100644 --- a/lib/widgets/popup_menu_codegen.dart +++ b/lib/widgets/popup_menu_codegen.dart @@ -1,6 +1,6 @@ -import 'package:apidash/consts.dart'; -import 'package:apidash/extensions/extensions.dart'; import 'package:flutter/material.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/consts.dart'; class CodegenPopupMenu extends StatelessWidget { const CodegenPopupMenu({ diff --git a/lib/widgets/popup_menu_env.dart b/lib/widgets/popup_menu_env.dart index 9b23ed9a..d61be5b4 100644 --- a/lib/widgets/popup_menu_env.dart +++ b/lib/widgets/popup_menu_env.dart @@ -1,24 +1,24 @@ -import 'package:apidash/consts.dart'; -import 'package:apidash/extensions/extensions.dart'; import 'package:flutter/material.dart'; +import 'package:apidash/extensions/extensions.dart'; import 'package:apidash/models/models.dart'; import 'package:apidash/utils/utils.dart'; +import 'package:apidash/consts.dart'; class EnvironmentPopupMenu extends StatelessWidget { const EnvironmentPopupMenu({ super.key, - this.activeEnvironment, + this.value, this.onChanged, - this.environments, + this.items, }); - final EnvironmentModel? activeEnvironment; + final EnvironmentModel? value; final void Function(EnvironmentModel? value)? onChanged; - final List? environments; + final List? items; final EnvironmentModel? noneEnvironmentModel = null; @override Widget build(BuildContext context) { - final activeEnvironmentName = getEnvironmentTitle(activeEnvironment?.name); + final valueName = getEnvironmentTitle(value?.name); final textClipLength = context.isCompactWindow ? 6 : 10; final double boxLength = context.isCompactWindow ? 100 : 130; return PopupMenuButton( @@ -34,7 +34,7 @@ class EnvironmentPopupMenu extends StatelessWidget { }, child: const Text("None"), ), - ...environments!.map((EnvironmentModel environment) { + ...items!.map((EnvironmentModel environment) { final name = getEnvironmentTitle(environment.name).clip(30); return PopupMenuItem( value: environment, @@ -55,9 +55,7 @@ class EnvironmentPopupMenu extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - activeEnvironment == null - ? "None" - : activeEnvironmentName.clip(textClipLength), + value == null ? "None" : valueName.clip(textClipLength), softWrap: false, ), const Icon( diff --git a/lib/widgets/popup_menu_history.dart b/lib/widgets/popup_menu_history.dart new file mode 100644 index 00000000..72398bb3 --- /dev/null +++ b/lib/widgets/popup_menu_history.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; + +class HistoryRetentionPopupMenu extends StatelessWidget { + const HistoryRetentionPopupMenu({ + super.key, + required this.value, + required this.onChanged, + this.items, + }); + + final HistoryRetentionPeriod value; + final void Function(HistoryRetentionPeriod value) onChanged; + final List? items; + @override + Widget build(BuildContext context) { + const double boxLength = 120; + return PopupMenuButton( + tooltip: "Select retention period", + surfaceTintColor: kColorTransparent, + constraints: const BoxConstraints(minWidth: boxLength), + itemBuilder: (BuildContext context) { + return [ + ...items!.map((period) { + return PopupMenuItem( + value: period, + child: Text( + period.label, + softWrap: false, + overflow: TextOverflow.ellipsis, + ), + ); + }) + ]; + }, + onSelected: onChanged, + child: Container( + width: boxLength, + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.label, + style: kTextStylePopupMenuItem, + overflow: TextOverflow.ellipsis, + ), + const Icon( + Icons.unfold_more, + size: 16, + ) + ], + ), + ), + ); + } +} diff --git a/lib/widgets/popup_menu_uri.dart b/lib/widgets/popup_menu_uri.dart index a6a3bb6c..5a62c97e 100644 --- a/lib/widgets/popup_menu_uri.dart +++ b/lib/widgets/popup_menu_uri.dart @@ -1,6 +1,6 @@ -import 'package:apidash/consts.dart'; -import 'package:apidash/extensions/extensions.dart'; import 'package:flutter/material.dart'; +import 'package:apidash/extensions/extensions.dart'; +import 'package:apidash/consts.dart'; class URIPopupMenu extends StatelessWidget { const URIPopupMenu({ @@ -15,7 +15,7 @@ class URIPopupMenu extends StatelessWidget { final List? items; @override Widget build(BuildContext context) { - final double boxLength = context.isCompactWindow ? 90 : 130; + final double boxLength = context.isCompactWindow ? 90 : 110; return PopupMenuButton( tooltip: "Select URI Scheme", surfaceTintColor: kColorTransparent, diff --git a/lib/widgets/request_widgets.dart b/lib/widgets/request_widgets.dart index 368bba5f..5ad400aa 100644 --- a/lib/widgets/request_widgets.dart +++ b/lib/widgets/request_widgets.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:apidash/extensions/extensions.dart'; import 'package:apidash/consts.dart'; import 'tabs.dart'; -import 'package:apidash/extensions/extensions.dart'; -class RequestPane extends StatefulWidget { +class RequestPane extends StatefulHookWidget { const RequestPane({ super.key, required this.selectedId, @@ -13,6 +14,7 @@ class RequestPane extends StatefulWidget { this.onTapTabBar, required this.children, this.showIndicators = const [false, false, false], + this.showViewCodeButton, }); final String? selectedId; @@ -22,6 +24,7 @@ class RequestPane extends StatefulWidget { final void Function(int)? onTapTabBar; final List children; final List showIndicators; + final bool? showViewCodeButton; @override State createState() => _RequestPaneState(); @@ -29,28 +32,19 @@ class RequestPane extends StatefulWidget { class _RequestPaneState extends State with TickerProviderStateMixin { - late final TabController _controller; - - @override - void initState() { - super.initState(); - _controller = TabController( - length: 3, - animationDuration: kTabAnimationDuration, - vsync: this, - ); - } - @override Widget build(BuildContext context) { + final TabController controller = useTabController( + initialLength: 3, + vsync: this, + ); if (widget.tabIndex != null) { - _controller.index = widget.tabIndex!; + controller.index = widget.tabIndex!; } return Column( children: [ - context.isMediumWindow - ? const SizedBox.shrink() - : Padding( + (widget.showViewCodeButton ?? !context.isMediumWindow) + ? Padding( padding: kP8, child: SizedBox( height: kHeaderHeight, @@ -58,6 +52,10 @@ class _RequestPaneState extends State mainAxisAlignment: MainAxisAlignment.end, children: [ FilledButton.tonalIcon( + style: FilledButton.styleFrom( + padding: kPh12, + minimumSize: const Size(44, 44), + ), onPressed: widget.onPressedCodeButton, icon: Icon( widget.codePaneVisible @@ -76,10 +74,11 @@ class _RequestPaneState extends State ], ), ), - ), + ) + : const SizedBox.shrink(), TabBar( key: Key(widget.selectedId!), - controller: _controller, + controller: controller, overlayColor: kColorTransparentState, labelPadding: kPh2, onTap: widget.onTapTabBar, @@ -101,7 +100,7 @@ class _RequestPaneState extends State kVSpacer5, Expanded( child: TabBarView( - controller: _controller, + controller: controller, physics: const NeverScrollableScrollPhysics(), children: widget.children, ), @@ -109,10 +108,4 @@ class _RequestPaneState extends State ], ); } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } } diff --git a/lib/widgets/response_widgets.dart b/lib/widgets/response_widgets.dart index ce07c100..0df9343b 100644 --- a/lib/widgets/response_widgets.dart +++ b/lib/widgets/response_widgets.dart @@ -128,6 +128,7 @@ class ResponsePaneHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final bool showClearButton = onClearResponse != null; return Padding( padding: kPv8, child: SizedBox( @@ -159,9 +160,11 @@ class ResponsePaneHeader extends StatelessWidget { ), ), kHSpacer10, - ClearResponseButton( - onPressed: onClearResponse, - ) + showClearButton + ? ClearResponseButton( + onPressed: onClearResponse, + ) + : const SizedBox.shrink(), ], ), ), diff --git a/lib/widgets/splitview_history.dart b/lib/widgets/splitview_history.dart new file mode 100644 index 00000000..b955eaeb --- /dev/null +++ b/lib/widgets/splitview_history.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:multi_split_view/multi_split_view.dart'; +import 'package:apidash/consts.dart'; + +class HistorySplitView extends StatefulWidget { + const HistorySplitView({ + super.key, + required this.sidebarWidget, + required this.mainWidget, + }); + + final Widget sidebarWidget; + final Widget mainWidget; + + @override + HistorySplitViewState createState() => HistorySplitViewState(); +} + +class HistorySplitViewState extends State { + final MultiSplitViewController _controller = MultiSplitViewController( + areas: [ + Area(id: "sidebar", min: 200, size: 250, max: 300), + 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.surfaceContainerHighest, + highlightedColor: Theme.of(context).colorScheme.outline.withOpacity( + kHintOpacity, + ), + 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(); + } +} diff --git a/lib/widgets/tabbar_segmented.dart b/lib/widgets/tabbar_segmented.dart new file mode 100644 index 00000000..bb58dc13 --- /dev/null +++ b/lib/widgets/tabbar_segmented.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; + +class SegmentedTabbar extends StatelessWidget { + const SegmentedTabbar({ + super.key, + required this.controller, + required this.tabs, + this.tabWidth = kSegmentedTabWidth, + this.tabHeight = kSegmentedTabHeight, + }); + + final TabController controller; + final List tabs; + final double tabWidth; + final double tabHeight; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + margin: kPh4, + width: tabWidth * tabs.length, + height: tabHeight, + decoration: BoxDecoration( + borderRadius: kBorderRadius20, + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: ClipRRect( + borderRadius: kBorderRadius20, + child: TabBar( + dividerColor: Colors.transparent, + indicatorWeight: 0.0, + indicatorSize: TabBarIndicatorSize.tab, + unselectedLabelColor: + Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + labelStyle: kTextStyleTab.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, + ), + unselectedLabelStyle: kTextStyleTab, + splashBorderRadius: kBorderRadius20, + indicator: BoxDecoration( + borderRadius: kBorderRadius20, + color: Theme.of(context).colorScheme.primary, + ), + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/tables.dart b/lib/widgets/table_map.dart similarity index 100% rename from lib/widgets/tables.dart rename to lib/widgets/table_map.dart diff --git a/lib/widgets/table_request.dart b/lib/widgets/table_request.dart new file mode 100644 index 00000000..8c3d9055 --- /dev/null +++ b/lib/widgets/table_request.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:apidash/consts.dart'; +import 'field_read_only.dart'; + +class RequestDataTable extends StatelessWidget { + const RequestDataTable({ + super.key, + required this.rows, + this.keyName, + this.valueName, + }); + + final Map rows; + final String? keyName; + final String? valueName; + + @override + Widget build(BuildContext context) { + final clrScheme = Theme.of(context).colorScheme; + + final List columns = [ + const DataColumn2( + label: Text(''), + fixedWidth: 8, + ), + DataColumn2( + label: Text(keyName ?? kNameField), + ), + const DataColumn2( + label: Text('='), + fixedWidth: 30, + ), + DataColumn2( + label: Text(valueName ?? kNameValue), + ), + const DataColumn2( + label: Text(''), + fixedWidth: 8, + ), + ]; + + final fieldDecoration = InputDecoration( + contentPadding: const EdgeInsets.only(bottom: 12), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: clrScheme.primary.withOpacity( + kHintOpacity, + ), + ), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: clrScheme.surfaceContainerHighest, + ), + ), + ); + + final List dataRows = rows.entries + .map( + (MapEntry entry) => DataRow( + cells: [ + const DataCell(kHSpacer5), + DataCell( + ReadOnlyTextField( + initialValue: entry.key, + decoration: fieldDecoration, + ), + ), + const DataCell( + Text('='), + ), + DataCell( + ReadOnlyTextField( + initialValue: entry.value, + decoration: fieldDecoration, + ), + ), + const DataCell(kHSpacer5), + ], + ), + ) + .toList(); + + return Container( + margin: kP10, + child: Column( + children: [ + Expanded( + child: Theme( + data: Theme.of(context) + .copyWith(scrollbarTheme: kDataTableScrollbarTheme), + child: DataTable2( + columnSpacing: 12, + dividerThickness: 0, + horizontalMargin: 0, + headingRowHeight: 0, + dataRowHeight: kDataTableRowHeight, + bottomMargin: kDataTableBottomPadding, + isVerticalScrollBarVisible: true, + columns: columns, + rows: dataRows, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/table_request_form.dart b/lib/widgets/table_request_form.dart new file mode 100644 index 00000000..91a1b39d --- /dev/null +++ b/lib/widgets/table_request_form.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; +import 'button_form_data_file.dart'; +import 'field_read_only.dart'; + +class RequestFormDataTable extends StatelessWidget { + const RequestFormDataTable({ + super.key, + required this.rows, + this.keyName, + this.valueName, + }); + + final List rows; + final String? keyName; + final String? valueName; + + @override + Widget build(BuildContext context) { + final clrScheme = Theme.of(context).colorScheme; + + final List columns = [ + const DataColumn2( + label: Text(''), + fixedWidth: 8, + ), + DataColumn2( + label: Text(keyName ?? kNameField), + ), + const DataColumn2( + label: Text('='), + fixedWidth: 30, + ), + DataColumn2( + label: Text(valueName ?? kNameValue), + ), + const DataColumn2( + label: Text(''), + fixedWidth: 8, + ), + ]; + + final fieldDecoration = InputDecoration( + contentPadding: const EdgeInsets.only(bottom: 12), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: clrScheme.primary.withOpacity( + kHintOpacity, + ), + ), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: clrScheme.surfaceContainerHighest, + ), + ), + ); + + final List dataRows = rows + .map( + (FormDataModel entry) => DataRow( + cells: [ + const DataCell(kHSpacer5), + DataCell( + ReadOnlyTextField( + initialValue: entry.name, + decoration: fieldDecoration, + ), + ), + const DataCell( + Text('='), + ), + DataCell( + entry.type == FormDataType.file + ? Tooltip( + message: entry.value, + child: FormDataFileButton( + onPressed: () {}, + initialValue: entry.value, + ), + ) + : ReadOnlyTextField( + initialValue: entry.value, + decoration: fieldDecoration, + ), + ), + const DataCell(kHSpacer5), + ], + ), + ) + .toList(); + + return Container( + margin: kP10, + child: Column( + children: [ + Expanded( + child: Theme( + data: Theme.of(context) + .copyWith(scrollbarTheme: kDataTableScrollbarTheme), + child: DataTable2( + columnSpacing: 12, + dividerThickness: 0, + horizontalMargin: 0, + headingRowHeight: 0, + dataRowHeight: kDataTableRowHeight, + bottomMargin: kDataTableBottomPadding, + isVerticalScrollBarVisible: true, + columns: columns, + rows: dataRows, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/texts.dart b/lib/widgets/texts.dart index f2cf5c92..71753bfc 100644 --- a/lib/widgets/texts.dart +++ b/lib/widgets/texts.dart @@ -32,3 +32,24 @@ class MethodBox extends StatelessWidget { ); } } + +class StatusCode extends StatelessWidget { + const StatusCode({super.key, required this.statusCode, this.style}); + final int statusCode; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + final Color color = + getResponseStatusCodeColor(statusCode, brightness: brightness); + return Text( + statusCode.toString(), + style: style?.copyWith(color: color) ?? + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: kCodeStyle.fontFamily, + color: color, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index b22ef4e8..06bc4bef 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -1,16 +1,21 @@ export 'button_clear_response.dart'; export 'button_copy.dart'; export 'button_discord.dart'; +export 'button_form_data_file.dart'; +export 'button_group_filled.dart'; export 'button_repo.dart'; export 'button_save_download.dart'; export 'button_send.dart'; +export 'card_history_request.dart'; export 'card_request_details.dart'; export 'card_sidebar_environment.dart'; +export 'card_sidebar_history.dart'; export 'card_sidebar_request.dart'; export 'checkbox.dart'; export 'code_previewer.dart'; export 'codegen_previewer.dart'; export 'dialog_about.dart'; +export 'dialog_history_retention.dart'; export 'dialog_import.dart'; export 'dialog_rename.dart'; export 'drag_and_drop_area.dart'; @@ -27,6 +32,7 @@ export 'field_cell.dart'; export 'field_header.dart'; export 'field_json_search.dart'; export 'field_raw.dart'; +export 'field_read_only.dart'; export 'field_url.dart'; export 'intro_message.dart'; export 'json_previewer.dart'; @@ -36,6 +42,7 @@ export 'menu_sidebar_top.dart'; export 'overlay_widget.dart'; export 'popup_menu_codegen.dart'; export 'popup_menu_env.dart'; +export 'popup_menu_history.dart'; export 'popup_menu_uri.dart'; export 'previewer.dart'; export 'request_widgets.dart'; @@ -44,7 +51,11 @@ export 'snackbars.dart'; export 'splitview_drawer.dart'; export 'splitview_dashboard.dart'; export 'splitview_equal.dart'; -export 'tables.dart'; +export 'splitview_history.dart'; +export 'tabbar_segmented.dart'; +export 'table_map.dart'; +export 'table_request_form.dart'; +export 'table_request.dart'; export 'tabs.dart'; export 'texts.dart'; export 'uint8_audio_player.dart'; diff --git a/pubspec.lock b/pubspec.lock index 731c3bcd..b04f6a8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -704,6 +704,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7d119b21..515fc0c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: just_audio_mpv: ^0.1.7 just_audio_windows: ^0.2.0 freezed_annotation: ^2.4.1 - json_annotation: ^4.8.1 + json_annotation: ^4.9.0 printing: ^5.12.0 package_info_plus: ^8.0.0 flutter_typeahead: ^5.2.0 @@ -63,6 +63,7 @@ dependencies: hooks_riverpod: ^2.5.1 flutter_hooks: ^0.20.5 flutter_portal: ^1.1.4 + intl: ^0.19.0 multi_trigger_autocomplete: git: url: https://github.com/foss42/multi_trigger_autocomplete.git diff --git a/test/codegen/csharp_rest_sharp_codgen_test.dart b/test/codegen/csharp_rest_sharp_codgen_test.dart index 6522cbee..df16f928 100644 --- a/test/codegen/csharp_rest_sharp_codgen_test.dart +++ b/test/codegen/csharp_rest_sharp_codgen_test.dart @@ -1,5 +1,5 @@ import 'package:apidash/consts.dart'; -import 'package:apidash/screens/home_page/editor_pane/details_card/code_pane.dart'; +import 'package:apidash/screens/common_widgets/common_widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../models/request_models.dart'; diff --git a/test/codegen/go_http_codegen_test.dart b/test/codegen/go_http_codegen_test.dart index 08492cf1..89e4c010 100644 --- a/test/codegen/go_http_codegen_test.dart +++ b/test/codegen/go_http_codegen_test.dart @@ -1,6 +1,6 @@ import 'package:apidash/codegen/codegen.dart'; import 'package:apidash/consts.dart'; -import 'package:apidash/screens/home_page/editor_pane/details_card/code_pane.dart'; +import 'package:apidash/screens/common_widgets/common_widgets.dart'; import 'package:test/test.dart'; import '../models/request_models.dart'; diff --git a/test/models/settings_model_test.dart b/test/models/settings_model_test.dart index 41fea3b3..6df01335 100644 --- a/test/models/settings_model_test.dart +++ b/test/models/settings_model_test.dart @@ -14,6 +14,7 @@ void main() { saveResponses: true, promptBeforeClosing: true, activeEnvironmentId: null, + historyRetentionPeriod: HistoryRetentionPeriod.oneWeek, ); test('Testing toJson()', () { @@ -28,7 +29,8 @@ void main() { "defaultCodeGenLang": "curl", "saveResponses": true, "promptBeforeClosing": true, - 'activeEnvironmentId': null + "activeEnvironmentId": null, + "historyRetentionPeriod": "oneWeek", }; expect(sm.toJson(), expectedResult); }); @@ -45,7 +47,8 @@ void main() { "defaultCodeGenLang": "curl", "saveResponses": true, "promptBeforeClosing": true, - 'activeEnvironmentId': null + "activeEnvironmentId": null, + "historyRetentionPeriod": "oneWeek", }; expect(SettingsModel.fromJson(input), sm); }); @@ -61,6 +64,7 @@ void main() { saveResponses: false, promptBeforeClosing: true, activeEnvironmentId: null, + historyRetentionPeriod: HistoryRetentionPeriod.oneWeek, ); expect( sm.copyWith( @@ -72,7 +76,7 @@ void main() { test('Testing toString()', () { const expectedResult = - "{isDark: false, alwaysShowCollectionPaneScrollbar: true, width: 300.0, height: 200.0, dx: 100.0, dy: 150.0, defaultUriScheme: http, defaultCodeGenLang: curl, saveResponses: true, promptBeforeClosing: true, activeEnvironmentId: null}"; + "{isDark: false, alwaysShowCollectionPaneScrollbar: true, width: 300.0, height: 200.0, dx: 100.0, dy: 150.0, defaultUriScheme: http, defaultCodeGenLang: curl, saveResponses: true, promptBeforeClosing: true, activeEnvironmentId: null, historyRetentionPeriod: oneWeek}"; expect(sm.toString(), expectedResult); }); diff --git a/test/providers/ui_providers_test.dart b/test/providers/ui_providers_test.dart index ba2b2b85..9b0653d3 100644 --- a/test/providers/ui_providers_test.dart +++ b/test/providers/ui_providers_test.dart @@ -6,13 +6,13 @@ import 'package:apidash/screens/common_widgets/common_widgets.dart'; import 'package:apidash/screens/dashboard.dart'; import 'package:apidash/screens/envvar/environment_page.dart'; import 'package:apidash/screens/home_page/collection_pane.dart'; -import 'package:apidash/screens/home_page/editor_pane/details_card/code_pane.dart'; import 'package:apidash/screens/home_page/editor_pane/details_card/response_pane.dart'; import 'package:apidash/screens/home_page/editor_pane/editor_default.dart'; import 'package:apidash/screens/home_page/editor_pane/editor_pane.dart'; import 'package:apidash/screens/home_page/editor_pane/url_card.dart'; import 'package:apidash/screens/home_page/home_page.dart'; import 'package:apidash/screens/settings_page.dart'; +import 'package:apidash/screens/history/history_page.dart'; import 'package:apidash/services/hive_services.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:extended_text_field/extended_text_field.dart'; @@ -60,6 +60,7 @@ void main() { // Verify that the HomePage is displayed initially expect(find.byType(HomePage), findsOneWidget); expect(find.byType(EnvironmentPage), findsNothing); + expect(find.byType(HistoryPage), findsNothing); expect(find.byType(SettingsPage), findsNothing); }); @@ -80,11 +81,12 @@ void main() { // Verify that the EnvironmentPage is displayed expect(find.byType(HomePage), findsNothing); expect(find.byType(EnvironmentPage), findsOneWidget); + expect(find.byType(HistoryPage), findsNothing); expect(find.byType(SettingsPage), findsNothing); }); testWidgets( - "Dashboard should display SettingsPage when navRailIndexStateProvider is 2", + "Dashboard should display HistorPage when navRailIndexStateProvider is 2", (WidgetTester tester) async { await tester.pumpWidget( ProviderScope( @@ -102,6 +104,29 @@ void main() { // Verify that the SettingsPage is displayed expect(find.byType(HomePage), findsNothing); expect(find.byType(EnvironmentPage), findsNothing); + expect(find.byType(HistoryPage), findsOneWidget); + expect(find.byType(SettingsPage), findsNothing); + }); + testWidgets( + "Dashboard should display SettingsPage when navRailIndexStateProvider is 3", + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + navRailIndexStateProvider.overrideWith((ref) => 3), + ], + child: const Portal( + child: MaterialApp( + home: Dashboard(), + ), + ), + ), + ); + + // Verify that the SettingsPage is displayed + expect(find.byType(HomePage), findsNothing); + expect(find.byType(EnvironmentPage), findsNothing); + expect(find.byType(HistoryPage), findsNothing); expect(find.byType(SettingsPage), findsOneWidget); }); @@ -125,7 +150,7 @@ void main() { // Verify that the navRailIndexStateProvider is updated final dashboard = tester.element(find.byType(Dashboard)); final container = ProviderScope.containerOf(dashboard); - expect(container.read(navRailIndexStateProvider), 2); + expect(container.read(navRailIndexStateProvider), 3); }); testWidgets( @@ -160,7 +185,7 @@ void main() { // Verify that the navRailIndexStateProvider still has the updated value final dashboard = tester.element(find.byType(Dashboard)); final container = ProviderScope.containerOf(dashboard); - expect(container.read(navRailIndexStateProvider), 2); + expect(container.read(navRailIndexStateProvider), 3); // Verify that the SettingsPage is still displayed after the rebuild expect(find.byType(SettingsPage), findsOneWidget); @@ -194,10 +219,19 @@ void main() { // Verify that the selected icon is the filled version (selectedIcon) expect(find.byIcon(Icons.computer_rounded), findsOneWidget); - // Go to SettingsPage + // Go to HistoryPage container.read(navRailIndexStateProvider.notifier).state = 2; await tester.pump(); + // Verify that the HistoryPage is displayed + expect(find.byType(HistoryPage), findsOneWidget); + // Verify that the selected icon is the filled version (selectedIcon) + expect(find.byIcon(Icons.history_rounded), findsOneWidget); + + // Go to SettingsPage + container.read(navRailIndexStateProvider.notifier).state = 3; + await tester.pump(); + // Verify that the SettingsPage is displayed expect(find.byType(SettingsPage), findsOneWidget); // Verify that the selected icon is the filled version (selectedIcon) diff --git a/test/widgets/tables_test.dart b/test/widgets/tables_test.dart index 70102cfe..46b4bc1a 100644 --- a/test/widgets/tables_test.dart +++ b/test/widgets/tables_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:apidash/widgets/tables.dart'; +import 'package:apidash/widgets/table_map.dart'; void main() { Map mapInput = {