diff --git a/modules/ensemble/lib/action/action_invokable.dart b/modules/ensemble/lib/action/action_invokable.dart index ac93e9a36..626b37d76 100644 --- a/modules/ensemble/lib/action/action_invokable.dart +++ b/modules/ensemble/lib/action/action_invokable.dart @@ -39,6 +39,7 @@ abstract class ActionInvokable with Invokable { ActionType.dismissDialog, ActionType.closeAllDialogs, ActionType.executeActionGroup, + ActionType.takeScreenshot ActionType.saveFile, ActionType.controlDeviceBackNavigation, ActionType.closeApp, diff --git a/modules/ensemble/lib/action/saveFile/save_file.dart b/modules/ensemble/lib/action/saveFile/save_file.dart index 47fc5a1f6..34c322d2c 100644 --- a/modules/ensemble/lib/action/saveFile/save_file.dart +++ b/modules/ensemble/lib/action/saveFile/save_file.dart @@ -1,17 +1,13 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'dart:io'; import 'package:ensemble/framework/action.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:flutter/material.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -// Conditionally import the file that has `dart:html` vs. the stub: -import 'download_stub.dart' if (dart.library.html) 'download_web.dart'; +import 'save_mobile.dart'; /// Custom action to save files (images and documents) in platform-specific accessible directories class SaveToFileSystemAction extends EnsembleAction { @@ -73,79 +69,24 @@ class SaveToFileSystemAction extends EnsembleAction { throw Exception('Missing blobData and source.'); } - if (type == 'image') { - // Save images to Default Image Path - await _saveImageToDCIM(fileName!, fileBytes); - } else if (type == 'document') { - // Save documents to Documents folder - await _saveDocumentToDocumentsFolder(fileName!, fileBytes); - } + // Save the file to the storage system + await _saveFile(type!, fileName!, fileBytes); } catch (e) { throw Exception('Failed to save file: $e'); } } - Future _saveImageToDCIM(String fileName, Uint8List fileBytes) async { - try { - if (kIsWeb) { - _downloadFileOnWeb(fileName, fileBytes); - } else { - final result = await ImageGallerySaver.saveImage( - fileBytes, - name: fileName, - ); - if (result['isSuccess']) { - debugPrint('Image saved to gallery: $result'); - } else { - throw Exception('Failed to save image to gallery.'); - } - } - } catch (e) { - throw Exception('Failed to save image: $e'); + Future _saveFile( + String type, String fileName, Uint8List fileBytes) async { + if (type == 'image') { + // Save images to Default Image Path + await saveImageToDCIM(fileName!, fileBytes); + } else if (type == 'document') { + // Save documents to Documents folder + await saveDocumentToDocumentsFolder(fileName!, fileBytes); } } - /// Save documents to the default "Documents" directory - Future _saveDocumentToDocumentsFolder( - String fileName, Uint8List fileBytes) async { - try { - String filePath; - - if (Platform.isAndroid) { - // Get the default "Documents" directory on Android - Directory? directory = Directory('/storage/emulated/0/Documents'); - if (!directory.existsSync()) { - directory.createSync( - recursive: true); // Create the directory if it doesn't exist - } - filePath = '${directory.path}/$fileName'; - } else if (Platform.isIOS) { - // On iOS, use the app-specific Documents directory - final directory = await getApplicationDocumentsDirectory(); - filePath = '${directory.path}/$fileName'; - - // Optionally, use a share intent to let users save the file to their desired location - } else if (kIsWeb) { - _downloadFileOnWeb(fileName, fileBytes); - return; - } else { - throw UnsupportedError('Platform not supported'); - } - - // Write the file to the determined path - final file = File(filePath); - await file.writeAsBytes(fileBytes); - - debugPrint('Document saved to: $filePath'); - } catch (e) { - throw Exception('Failed to save document: $e'); - } - } - - Future _downloadFileOnWeb(String fileName, Uint8List fileBytes) async { - downloadFileOnWeb(fileName, fileBytes); - } - /// Factory method to construct the action from JSON static SaveToFileSystemAction fromJson(Map json) { return SaveToFileSystemAction( diff --git a/modules/ensemble/lib/action/saveFile/save_mobile.dart b/modules/ensemble/lib/action/saveFile/save_mobile.dart new file mode 100644 index 000000000..f8bcad0b1 --- /dev/null +++ b/modules/ensemble/lib/action/saveFile/save_mobile.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; +// Conditionally import the file that has `dart:html` vs. the stub: +import 'download_stub.dart' if (dart.library.html) 'download_web.dart'; + +Future saveImageToDCIM(String fileName, Uint8List fileBytes) async { + try { + if (kIsWeb) { + downloadFileOnWeb(fileName, fileBytes); + } else { + final result = await ImageGallerySaver.saveImage( + fileBytes, + name: fileName, + ); + if (result['isSuccess']) { + debugPrint('Image saved to gallery: $result'); + } else { + throw Exception('Failed to save image to gallery.'); + } + } + } catch (e) { + throw Exception('Failed to save image: $e'); + } +} + +/// Save documents to the default "Documents" directory +Future saveDocumentToDocumentsFolder( + String fileName, Uint8List fileBytes) async { + try { + String filePath; + + if (Platform.isAndroid) { + // Get the default "Documents" directory on Android + Directory? directory = Directory('/storage/emulated/0/Documents'); + if (!directory.existsSync()) { + directory.createSync( + recursive: true); // Create the directory if it doesn't exist + } + filePath = '${directory.path}/$fileName'; + } else if (Platform.isIOS) { + // On iOS, use the app-specific Documents directory + final directory = await getApplicationDocumentsDirectory(); + filePath = '${directory.path}/$fileName'; + + // Optionally, use a share intent to let users save the file to their desired location + } else if (kIsWeb) { + downloadFileOnWeb(fileName, fileBytes); + return; + } else { + throw UnsupportedError('Platform not supported'); + } + + // Write the file to the determined path + final file = File(filePath); + await file.writeAsBytes(fileBytes); + + debugPrint('Document saved to: $filePath'); + } catch (e) { + throw Exception('Failed to save document: $e'); + } +} diff --git a/modules/ensemble/lib/action/take_screenshot.dart b/modules/ensemble/lib/action/take_screenshot.dart new file mode 100644 index 000000000..8d7be85da --- /dev/null +++ b/modules/ensemble/lib/action/take_screenshot.dart @@ -0,0 +1,129 @@ +import 'dart:typed_data'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:screenshot/screenshot.dart'; +import 'saveFile/save_mobile.dart'; + +class TakeScreenshotAction extends EnsembleAction { + TakeScreenshotAction({ + super.initiator, + required this.widgetId, + this.pixelRatio, + this.onSuccess, + this.onError, + }); + + final dynamic widgetId; + final double? pixelRatio; + final EnsembleAction? onSuccess; + final EnsembleAction? onError; + + factory TakeScreenshotAction.fromYaml({Map? payload}) { + if (payload == null || payload['widgetId'] == null) { + throw LanguageError( + "${ActionType.takeScreenshot.name} requires 'widgetId'"); + } + return TakeScreenshotAction( + widgetId: payload['widgetId'], + pixelRatio: Utils.optionalDouble(payload['pixelRatio']), + onSuccess: payload['onSuccess'] != null + ? EnsembleAction.from(payload['onSuccess']) + : null, + onError: payload['onError'] != null + ? EnsembleAction.from(payload['onError']) + : null, + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + final screenshotController = ScreenshotController(); + + try { + final resolvedWidget = scopeManager.dataContext.eval(widgetId); + if (resolvedWidget == null) { + throw LanguageError("Widget not found: '$widgetId'"); + } + + final widget = Screenshot( + controller: screenshotController, + child: DataScopeWidget( + scopeManager: scopeManager, + child: resolvedWidget, + ), + ); + + final Uint8List? capturedImage = + // It will only capture readonly widgets + await screenshotController.captureFromLongWidget( + InheritedTheme.captureAll( + context, + Material( + type: MaterialType.transparency, + child: widget, + ), + ), + pixelRatio: pixelRatio ?? MediaQuery.of(context).devicePixelRatio, + context: context, + ); + + if (capturedImage == null) { + throw LanguageError("Failed to capture screenshot"); + } + + final dimensions = await _getImageDimensions(capturedImage); + // Save screenshot to gallery and download on web + await _saveScreenshot(capturedImage); + + if (onSuccess != null) { + await ScreenController().executeAction( + context, + onSuccess!, + event: EnsembleEvent(initiator, data: { + 'imageBytes': capturedImage, // capturedImage contains Image Bytes + 'size': capturedImage.length, + 'dimensions': dimensions, + }), + ); + } + } catch (e) { + if (onError != null) { + await ScreenController().executeAction( + context, + onError!, + event: EnsembleEvent(initiator, data: {'error': e.toString()}), + ); + } + } + } + + Future _saveScreenshot(Uint8List fileBytes) async { + // Screenshot name of current date + // Get the current date and time + DateTime now = DateTime.now(); + + // Format the date and time + String formattedDateTime = DateFormat('yyyyMMdd_HHmmss').format(now); + + // Combine the prefix with the formatted date and time + String screenshotName = 'screenshot_$formattedDateTime'; + + await saveImageToDCIM(screenshotName, fileBytes); + } + + Future> _getImageDimensions(Uint8List imageData) async { + final image = await decodeImageFromList(imageData); + return { + 'width': image.width, + 'height': image.height, + }; + } +} diff --git a/modules/ensemble/lib/framework/action.dart b/modules/ensemble/lib/framework/action.dart index 537eb977b..7fa15e71a 100644 --- a/modules/ensemble/lib/framework/action.dart +++ b/modules/ensemble/lib/framework/action.dart @@ -22,6 +22,7 @@ import 'package:ensemble/action/saveFile/save_file.dart'; import 'package:ensemble/action/phone_contact_action.dart'; import 'package:ensemble/action/sign_in_out_action.dart'; import 'package:ensemble/action/toast_actions.dart'; +import 'package:ensemble/action/take_screenshot.dart'; import 'package:ensemble/action/disable_hardware_navigation.dart'; import 'package:ensemble/action/close_app.dart'; import 'package:ensemble/ensemble.dart'; @@ -1042,6 +1043,7 @@ enum ActionType { dispatchEvent, executeConditionalAction, executeActionGroup, + takeScreenshot, playAudio, stopAudio, pauseAudio, @@ -1165,6 +1167,8 @@ abstract class EnsembleAction { return FilePickerAction.fromYaml(payload: payload); } else if (actionType == ActionType.openUrl) { return OpenUrlAction.fromYaml(payload: payload); + } else if (actionType == ActionType.takeScreenshot) { + return TakeScreenshotAction.fromYaml(payload: payload); } else if (actionType == ActionType.connectWallet) { return WalletConnectAction.fromYaml(payload: payload); } else if (actionType == ActionType.authorizeOAuthService) { diff --git a/modules/ensemble/pubspec.yaml b/modules/ensemble/pubspec.yaml index a0bcfb4b5..623071a18 100644 --- a/modules/ensemble/pubspec.yaml +++ b/modules/ensemble/pubspec.yaml @@ -110,6 +110,7 @@ dependencies: web_socket_client: ^0.1.0 app_links: ^6.3.2 share_plus: ^10.0.3 + screenshot: ^3.0.0 rate_my_app: ^2.0.0 table_calendar: git: