diff --git a/README.md b/README.md index f6f73d62..3297c14e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/app.dart b/lib/app.dart index f60198af..70e95213 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -57,7 +57,7 @@ class _AppState extends ConsumerState 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( diff --git a/lib/consts.dart b/lib/consts.dart index 2ad9d185..e9e58830 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -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>> 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."; diff --git a/lib/models/request_model.dart b/lib/models/request_model.dart index acf6a3eb..d8b9d6af 100644 --- a/lib/models/request_model.dart +++ b/lib/models/request_model.dart @@ -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? 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, ); } diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index e55ab8bd..ec4d36f9 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -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 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 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> exportDataToHAR() async { diff --git a/lib/providers/ui_providers.dart b/lib/providers/ui_providers.dart index 62f83f70..e4b10677 100644 --- a/lib/providers/ui_providers.dart +++ b/lib/providers/ui_providers.dart @@ -6,6 +6,8 @@ final selectedIdEditStateProvider = StateProvider((ref) => null); final codePaneVisibleStateProvider = StateProvider((ref) => false); final saveDataStateProvider = StateProvider((ref) => false); final clearDataStateProvider = StateProvider((ref) => false); +final hasUnsavedChangesProvider = StateProvider((ref) => false); + // final nameTextFieldControllerProvider = // StateProvider.autoDispose((ref) { // TextEditingController controller = TextEditingController(text: ""); diff --git a/lib/screens/home_page/collection_pane.dart b/lib/screens/home_page/collection_pane.dart index 0ede2021..934c5b66 100644 --- a/lib/screens/home_page/collection_pane.dart +++ b/lib/screens/home_page/collection_pane.dart @@ -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( diff --git a/lib/screens/home_page/editor_pane/details_card/response_pane.dart b/lib/screens/home_page/editor_pane/details_card/response_pane.dart index c1c770cf..17a03ea7 100644 --- a/lib/screens/home_page/editor_pane/details_card/response_pane.dart +++ b/lib/screens/home_page/editor_pane/details_card/response_pane.dart @@ -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(); diff --git a/lib/widgets/previewer.dart b/lib/widgets/previewer.dart index dd2f5186..ac213b50 100644 --- a/lib/widgets/previewer.dart +++ b/lib/widgets/previewer.dart @@ -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 { 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" diff --git a/lib/widgets/response_widgets.dart b/lib/widgets/response_widgets.dart index 0b570c19..a3d9f20b 100644 --- a/lib/widgets/response_widgets.dart +++ b/lib/widgets/response_widgets.dart @@ -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 createState() => _SendingWidgetState(); +} + +class _SendingWidgetState extends State { + 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( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Lottie.asset(kAssetSendingLottie), - ], - ), + return Stack( + children: [ + Center( + child: Lottie.asset(kAssetSendingLottie), + ), + Padding( + padding: kPh20t40, + child: Visibility( + visible: _millisecondsElapsed >= 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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, + ), + ), + ], + ), + ), + ), + ], ); } } diff --git a/lib/widgets/video_previewer.dart b/lib/widgets/video_previewer.dart new file mode 100644 index 00000000..c077e569 --- /dev/null +++ b/lib/widgets/video_previewer.dart @@ -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 createState() => _VideoPreviewerState(); +} + +class _VideoPreviewerState extends State { + 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(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 3d7a7189..dfb8e077 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index cfa20cb3..3cd9281e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/widgets/previewer_test.dart b/test/widgets/previewer_test.dart index c26793bb..c8a33f62 100644 --- a/test/widgets/previewer_test.dart +++ b/test/widgets/previewer_test.dart @@ -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 { diff --git a/test/widgets/response_widgets_test.dart b/test/widgets/response_widgets_test.dart index f6f3ecdd..c5617035 100644 --- a/test/widgets/response_widgets_test.dart +++ b/test/widgets/response_widgets_test.dart @@ -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(