Merge branch 'add-ui-tests' of https://github.com/sixtusagbo/apidash into add-ui-tests

This commit is contained in:
Sixtus Agbo
2024-03-29 00:39:25 +01:00
15 changed files with 363 additions and 23 deletions

View File

@ -157,7 +157,16 @@ Here is the complete list of mimetypes that can be directly previewed in API Das
| File Type | Mimetype | Extension | Comment |
| --------- | -------------------------- | ----------------- | -------- |
| PDF | `application/pdf` | `.pdf` | |
| CSV | `text/csv` | `.csv` | Can be improved |
| Video | `video/mp4` | `.mp4` | |
| Video | `video/webm` | `.webm` | |
| Video | `video/x-ms-wmv` | `.wmv` | |
| Video | `video/x-ms-asf` | `.wmv` | |
| Video | `video/avi` | `.avi` | |
| Video | `video/msvideo` | `.avi` | |
| Video | `video/x-msvideo` | `.avi` | |
| Video | `video/quicktime` | `.mov` | |
| Video | `video/x-quicktime` | `.mov` | |
| Video | `video/x-matroska` | `.mkv` | |
| Image | `image/apng` | `.apng` | Animated |
| Image | `image/avif` | `.avif` | |
| Image | `image/bmp` | `.bmp` | |
@ -188,6 +197,7 @@ Here is the complete list of mimetypes that can be directly previewed in API Das
| Audio | `audio/x-m4a` | `.m4a` | |
| Audio | `audio/wav` | `.wav` | |
| Audio | `audio/wave` | `.wav` | |
| CSV | `text/csv` | `.csv` | Can be improved |
We welcome PRs to add support for previewing other multimedia mimetypes. Please go ahead and raise an issue so that we can discuss the approach.
We are adding support for other mimetypes with each release. But, if you are looking for any particular mimetype support, please go ahead and open an issue. We will prioritize it's addition.

View File

@ -57,7 +57,7 @@ class _AppState extends ConsumerState<App> with WindowListener {
bool isPreventClose = await windowManager.isPreventClose();
if (isPreventClose) {
if (ref.watch(
settingsProvider.select((value) => value.promptBeforeClosing))) {
settingsProvider.select((value) => value.promptBeforeClosing)) && ref.watch(hasUnsavedChangesProvider)) {
showDialog(
context: context,
builder: (_) => AlertDialog(

View File

@ -21,7 +21,7 @@ final kIsLinux = !kIsWeb && Platform.isLinux;
final kIsApple = !kIsWeb && (Platform.isIOS || Platform.isMacOS);
final kIsDesktop =
!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
final kIsRunningTests = Platform.environment.containsKey('FLUTTER_TEST');
final kIsIOS = !kIsWeb && Platform.isIOS;
final kIsAndroid = !kIsWeb && Platform.isAndroid;
final kIsMobile = !kIsWeb && (Platform.isIOS || Platform.isAndroid);
@ -403,7 +403,7 @@ const Map<String, Map<String, List<ResponseBodyView>>>
kSubTypeDefaultViewOptions: kPreviewBodyViewOptions,
},
kTypeVideo: {
kSubTypeDefaultViewOptions: kNoBodyViewOptions,
kSubTypeDefaultViewOptions: kPreviewBodyViewOptions,
},
kTypeText: {
kSubTypeDefaultViewOptions: kRawBodyViewOptions,
@ -512,6 +512,9 @@ const kMimeTypeRaiseIssue =
const kUnexpectedRaiseIssue =
"\nIf the behaviour is unexpected, please raise an issue in API Dash GitHub repo so that we can resolve it.";
const kVideoError =
"There seems to be an issue playing this video. Please raise an issue in API Dash GitHub repo so that we can resolve it.";
const kImageError =
"There seems to be an issue rendering this image. Please raise an issue in API Dash GitHub repo so that we can resolve it.";

View File

@ -31,6 +31,7 @@ class RequestModel {
this.message,
this.responseModel,
this.isWorking = false,
this.sendingTime,
});
final String id;
@ -50,6 +51,7 @@ class RequestModel {
final String? message;
final ResponseModel? responseModel;
final bool isWorking;
final DateTime? sendingTime;
List<NameValueModel>? get enabledRequestHeaders =>
getEnabledRows(requestHeaders, isHeaderEnabledList);
@ -135,6 +137,7 @@ class RequestModel {
String? message,
ResponseModel? responseModel,
bool? isWorking,
DateTime? sendingTime,
}) {
var headers = requestHeaders ?? this.requestHeaders;
var params = requestParams ?? this.requestParams;
@ -160,6 +163,7 @@ class RequestModel {
message: message ?? this.message,
responseModel: responseModel ?? this.responseModel,
isWorking: isWorking ?? this.isWorking,
sendingTime: sendingTime ?? this.sendingTime,
);
}

View File

@ -66,6 +66,7 @@ class CollectionStateNotifier
.read(requestSequenceProvider.notifier)
.update((state) => [id, ...state]);
ref.read(selectedIdStateProvider.notifier).state = newRequestModel.id;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
void reorder(int oldIdx, int newIdx) {
@ -73,6 +74,7 @@ class CollectionStateNotifier
final itemId = itemIds.removeAt(oldIdx);
itemIds.insert(newIdx, itemId);
ref.read(requestSequenceProvider.notifier).state = [...itemIds];
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
void remove(String id) {
@ -95,6 +97,7 @@ class CollectionStateNotifier
var map = {...state!};
map.remove(id);
state = map;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
void clearResponse(String? id) {
@ -108,6 +111,7 @@ class CollectionStateNotifier
var map = {...state!};
map[id] = newModel;
state = map;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
void duplicate(String id) {
@ -127,6 +131,7 @@ class CollectionStateNotifier
ref.read(requestSequenceProvider.notifier).state = [...itemIds];
ref.read(selectedIdStateProvider.notifier).state = newId;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
void update(
@ -168,6 +173,7 @@ class CollectionStateNotifier
var map = {...state!};
map[id] = newModel;
state = map;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
Future<void> sendRequest(String id) async {
@ -182,7 +188,10 @@ class CollectionStateNotifier
// set current model's isWorking to true and update state
var map = {...state!};
map[id] = requestModel.copyWith(isWorking: true);
map[id] = requestModel.copyWith(
isWorking: true,
sendingTime: DateTime.now(),
);
state = map;
(http.Response?, Duration?, String?)? responseRec = await request(
@ -214,6 +223,7 @@ class CollectionStateNotifier
map = {...state!};
map[id] = newRequestModel;
state = map;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
Future<void> clearData() async {
@ -223,6 +233,7 @@ class CollectionStateNotifier
ref.read(clearDataStateProvider.notifier).state = false;
ref.read(requestSequenceProvider.notifier).state = [];
state = {};
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
bool loadData() {
@ -263,6 +274,7 @@ class CollectionStateNotifier
}
await hiveHandler.removeUnused();
ref.read(saveDataStateProvider.notifier).state = false;
ref.read(hasUnsavedChangesProvider.notifier).state = false;
}
Future<Map<String, dynamic>> exportDataToHAR() async {

View File

@ -6,6 +6,8 @@ final selectedIdEditStateProvider = StateProvider<String?>((ref) => null);
final codePaneVisibleStateProvider = StateProvider<bool>((ref) => false);
final saveDataStateProvider = StateProvider<bool>((ref) => false);
final clearDataStateProvider = StateProvider<bool>((ref) => false);
final hasUnsavedChangesProvider = StateProvider<bool>((ref) => false);
// final nameTextFieldControllerProvider =
// StateProvider.autoDispose<TextEditingController>((ref) {
// TextEditingController controller = TextEditingController(text: "");

View File

@ -15,6 +15,7 @@ class CollectionPane extends ConsumerWidget {
final overlayWidget = OverlayWidgetTemplate(context: context);
final collection = ref.watch(collectionStateNotifierProvider);
final savingData = ref.watch(saveDataStateProvider);
final hasUnsavedChanges = ref.watch(hasUnsavedChangesProvider);
if (collection == null) {
return const Center(
child: CircularProgressIndicator(),
@ -31,7 +32,7 @@ class CollectionPane extends ConsumerWidget {
alignment: WrapAlignment.spaceBetween,
children: [
TextButton.icon(
onPressed: savingData
onPressed: (savingData || !hasUnsavedChanges)
? null
: () async {
overlayWidget.show(

View File

@ -12,12 +12,17 @@ class ResponsePane extends ConsumerWidget {
final isWorking = ref.watch(
selectedRequestModelProvider.select((value) => value?.isWorking)) ??
false;
final startSendingTime = ref.watch(
selectedRequestModelProvider.select((value) => value?.sendingTime));
final responseStatus = ref.watch(
selectedRequestModelProvider.select((value) => value?.responseStatus));
final message = ref
.watch(selectedRequestModelProvider.select((value) => value?.message));
if (isWorking) {
return const SendingWidget();
return SendingWidget(
startSendingTime: startSendingTime,
);
}
if (responseStatus == null) {
return const NotSentWidget();

View File

@ -8,6 +8,7 @@ import 'error_message.dart';
import 'uint8_audio_player.dart';
import 'json_previewer.dart';
import 'csv_previewer.dart';
import 'video_previewer.dart';
import '../consts.dart';
class Previewer extends StatefulWidget {
@ -86,7 +87,12 @@ class _PreviewerState extends State<Previewer> {
return CsvPreviewer(body: widget.body);
}
if (widget.type == kTypeVideo) {
// TODO: Video Player
try {
var preview = VideoPreviewer(videoBytes: widget.bytes);
return preview;
} catch (e) {
return const ErrorMessage(message: kVideoError);
}
}
String message = widget.hasRaw
? "$kMimeTypeRawRaiseIssueStart${widget.type}/${widget.subtype}$kMimeTypeRaiseIssue"

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http_parser/http_parser.dart';
@ -33,18 +34,80 @@ class NotSentWidget extends StatelessWidget {
}
}
class SendingWidget extends StatelessWidget {
const SendingWidget({super.key});
class SendingWidget extends StatefulWidget {
final DateTime? startSendingTime;
const SendingWidget({
super.key,
required this.startSendingTime,
});
@override
State<SendingWidget> createState() => _SendingWidgetState();
}
class _SendingWidgetState extends State<SendingWidget> {
int _millisecondsElapsed = 0;
Timer? _timer;
@override
void initState() {
super.initState();
if (widget.startSendingTime != null) {
_millisecondsElapsed =
(DateTime.now().difference(widget.startSendingTime!).inMilliseconds ~/
100) *
100;
_timer = Timer.periodic(const Duration(milliseconds: 100), _updateTimer);
}
}
void _updateTimer(Timer timer) {
setState(() {
_millisecondsElapsed += 100;
});
}
@override
void dispose() {
if (_timer != null && _timer!.isActive) _timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
return Stack(
children: [
Center(
child: Lottie.asset(kAssetSendingLottie),
),
Padding(
padding: kPh20t40,
child: Visibility(
visible: _millisecondsElapsed >= 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Lottie.asset(kAssetSendingLottie),
Icon(
Icons.alarm,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(
width: 10,
),
Text(
'Time elapsed: ${humanizeDuration(Duration(milliseconds: _millisecondsElapsed))}',
textAlign: TextAlign.center,
overflow: TextOverflow.fade,
softWrap: false,
style: kTextStyleButton.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
],
);
}
}

View File

@ -0,0 +1,147 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:apidash/consts.dart';
import 'package:fvp/fvp.dart' as fvp;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:video_player/video_player.dart';
import 'package:path_provider/path_provider.dart';
class VideoPreviewer extends StatefulWidget {
const VideoPreviewer({
super.key,
required this.videoBytes,
});
final Uint8List videoBytes;
@override
State<VideoPreviewer> createState() => _VideoPreviewerState();
}
class _VideoPreviewerState extends State<VideoPreviewer> {
VideoPlayerController? _videoController;
bool _isPlaying = false;
File? _tempVideoFile;
bool _showControls = false;
@override
void initState() {
super.initState();
registerWithAllPlatforms();
_initializeVideoPlayer();
}
void registerWithAllPlatforms() {
try {
fvp.registerWith();
} catch (e) {
// pass
}
}
void _initializeVideoPlayer() async {
final tempDir = await getTemporaryDirectory();
_tempVideoFile = File(
'${tempDir.path}/temp_video_${DateTime.now().millisecondsSinceEpoch}');
try {
await _tempVideoFile?.writeAsBytes(widget.videoBytes);
_videoController = VideoPlayerController.file(_tempVideoFile!)
..initialize().then((_) {
if (mounted) {
setState(() {
_videoController!.play();
_videoController!.setLooping(true);
});
}
});
} catch (e) {
return;
}
}
@override
Widget build(BuildContext context) {
final iconColor = Theme.of(context).iconTheme.color;
final progressBarColors = VideoProgressColors(
playedColor: iconColor!,
bufferedColor: iconColor.withOpacity(0.5),
backgroundColor: iconColor.withOpacity(0.3),
);
return Scaffold(
body: MouseRegion(
onEnter: (_) => setState(() => _showControls = true),
onExit: (_) => setState(() => _showControls = false),
child: Stack(
children: [
Center(
child: _videoController?.value.isInitialized == true
? AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
)
: const CircularProgressIndicator(),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _videoController?.value.isInitialized == true
? SizedBox(
height: 50.0,
child: VideoProgressIndicator(
_videoController!,
allowScrubbing: true,
padding: const EdgeInsets.all(20),
colors: progressBarColors,
),
)
: Container(height: 0),
),
if (_showControls)
Center(
child: GestureDetector(
onTap: () {
if (_videoController!.value.isPlaying) {
_videoController!.pause();
} else {
_videoController!.play();
}
setState(() {
_isPlaying = !_isPlaying;
});
},
child: Container(
color: Colors.transparent,
child: Icon(
_isPlaying ? Icons.play_arrow : Icons.pause,
size: 64,
color: iconColor,
),
),
),
),
],
),
),
);
}
@override
void dispose() {
_videoController?.pause();
_videoController?.dispose();
if (!kIsRunningTests) {
Future.delayed(const Duration(seconds: 1), () async {
try {
if (_tempVideoFile != null) {
await _tempVideoFile!.delete();
}
} catch (e) {
return;
}
});
}
super.dispose();
}
}

View File

@ -225,6 +225,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
csslib:
dependency: transitive
description:
name: csslib
sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
csv:
dependency: "direct main"
description:
@ -504,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.0"
fvp:
dependency: "direct main"
description:
name: fvp
sha256: "995328479ba4641da6760ddc84a168db157a3b9db4f0417fa68713d99344a146"
url: "https://pub.dev"
source: hosted
version: "0.14.0"
glob:
dependency: transitive
description:
@ -552,6 +568,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
html:
dependency: transitive
description:
name: html
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
url: "https://pub.dev"
source: hosted
version: "0.15.4"
html_unescape:
dependency: transitive
description:
@ -1350,6 +1374,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: afc65f4b8bcb2c188f64a591f84fb471f4f2e19fc607c65fd8d2f8fedb3dec23
url: "https://pub.dev"
source: hosted
version: "2.8.3"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "4dd9b8b86d70d65eecf3dcabfcdfbb9c9115d244d022654aba49a00336d540c2"
url: "https://pub.dev"
source: hosted
version: "2.4.12"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "309e3962795e761be010869bae65c0b0e45b5230c5cee1bec72197ca7db040ed"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
video_player_platform_interface:
dependency: "direct main"
description:
name: video_player_platform_interface
sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6"
url: "https://pub.dev"
source: hosted
version: "6.2.2"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "41245cef5ef29c4585dbabcbcbe9b209e34376642c7576cabf11b4ad9289d6e4"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
vm_service:
dependency: transitive
description:

View File

@ -44,6 +44,9 @@ dependencies:
package_info_plus: ^5.0.1
flutter_typeahead: ^5.2.0
provider: ^6.1.2
fvp: ^0.14.0
video_player: ^2.3.2
video_player_platform_interface: ^6.2.2
json_data_explorer:
git:
url: https://github.com/foss42/json_data_explorer.git

View File

@ -5,6 +5,7 @@ import 'package:apidash/consts.dart';
import 'package:flutter/foundation.dart';
import 'package:printing/printing.dart' show PdfPreview;
import 'package:flutter_svg/flutter_svg.dart' show SvgPicture;
import 'package:apidash/widgets/video_previewer.dart';
import '../test_consts.dart';
void main() {
@ -63,10 +64,7 @@ void main() {
),
),
);
expect(
find.text("${kMimeTypeRaiseIssueStart}video/H264$kMimeTypeRaiseIssue"),
findsOneWidget);
expect(find.byType(VideoPreviewer), findsOneWidget);
});
testWidgets('Testing when type/subtype is model/step+xml', (tester) async {

View File

@ -10,13 +10,15 @@ import 'package:apidash/models/models.dart';
import '../test_consts.dart';
void main() {
testWidgets('Testing Sending Widget', (tester) async {
testWidgets('Testing Sending Widget Without Timer', (tester) async {
await tester.pumpWidget(
MaterialApp(
title: 'Send',
theme: kThemeDataDark,
home: const Scaffold(
body: SendingWidget(),
body: SendingWidget(
startSendingTime: null,
),
),
),
);
@ -24,6 +26,26 @@ void main() {
expect(find.byType(Lottie), findsOneWidget);
});
testWidgets('Testing Sending Widget With Timer', (tester) async {
await tester.pumpWidget(
MaterialApp(
title: 'Send',
theme: kThemeDataDark,
home: Scaffold(
body: SendingWidget(
startSendingTime: DateTime.now(),
),
),
),
);
expect(find.text('Time elapsed: 0 ms'), findsOneWidget);
expect(find.byType(Lottie), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
expect(find.text('Time elapsed: 1.00 s'), findsOneWidget);
});
testWidgets('Testing Not Sent Widget', (tester) async {
await tester.pumpWidget(
const MaterialApp(