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/misc_action.dart b/modules/ensemble/lib/action/misc_action.dart index f86dd2efe..06b2fa454 100644 --- a/modules/ensemble/lib/action/misc_action.dart +++ b/modules/ensemble/lib/action/misc_action.dart @@ -11,6 +11,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:rate_my_app/rate_my_app.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:mime/mime.dart'; class CopyToClipboardAction extends EnsembleAction { CopyToClipboardAction(this._value, @@ -57,29 +59,143 @@ class CopyToClipboardAction extends EnsembleAction { } } -/// Share a text (an optionally a title) to external Apps +/// Share text and files (an optionally a title) to external Apps class ShareAction extends EnsembleAction { - ShareAction(this._text, {String? title}) : _title = title; + ShareAction(this._text, {String? title, dynamic files}) + : _title = title, + _files = files; String? _title; dynamic _text; + dynamic _files; factory ShareAction.from({Map? payload}) { - if (payload == null || payload['text'] == null) { - throw LanguageError("${ActionType.share.name} requires 'text'"); + if (payload == null || + (payload['text'] == null && payload['files'] == null)) { + throw LanguageError( + "${ActionType.share.name} requires 'text' or 'files'"); } - return ShareAction(payload['text'], title: payload['title']?.toString()); + + return ShareAction( + payload['text'], + title: payload['title']?.toString(), + files: payload['files'], + ); + } + + // Helper method to create XFile from file data + XFile? createXFile(dynamic file) { + final mimeType = + lookupMimeType(file["path"] ?? '', headerBytes: file["bytes"]) ?? + 'application/octet-stream'; + try { + if (file is Map) { + // Handle file with path + if (file['path'] != null && file['path'].toString().isNotEmpty) { + final String path = file['path'].toString(); + final String name = file['name']?.toString() ?? path.split('/').last; + return XFile(path, name: name, mimeType: mimeType); + } + + // Handle file with bytes (web) + if (file.containsKey('bytes') && file['bytes'] != null) { + final String name = file['name']?.toString() ?? 'file'; + + return XFile.fromData( + file['bytes'], + name: name, + mimeType: mimeType, + ); + } + } else if (file is String) { + // Handle simple file path string + return XFile(file, name: file.split('/').last, mimeType: mimeType); + } + } catch (e) { + debugPrint('Error creating XFile: $e'); + } + return null; } @override - Future execute(BuildContext context, ScopeManager scopeManager) { - final box = context.findRenderObject() as RenderBox?; + Future execute(BuildContext context, ScopeManager scopeManager) async { + try { + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; - Share.share( - scopeManager.dataContext.eval(_text), - subject: Utils.optionalString(scopeManager.dataContext.eval(_title)), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ); - return Future.value(null); + final sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; + final evaluatedText = + scopeManager.dataContext.eval(_text)?.toString() ?? ''; + final evaluatedTitle = + Utils.optionalString(scopeManager.dataContext.eval(_title)); + + // Handle file sharing + if (_files != null) { + final evaluatedFiles = scopeManager.dataContext.eval(_files); + if (evaluatedFiles != null) { + final filesList = + evaluatedFiles is List ? evaluatedFiles : [evaluatedFiles]; + final List xFiles = []; + + // Create XFiles + for (var file in filesList) { + final xFile = createXFile(file); + if (xFile != null) { + xFiles.add(xFile); + } + } + + // Share files if any were created successfully + if (xFiles.isNotEmpty) { + try { + final result = await Share.shareXFiles( + xFiles, + text: evaluatedText, + subject: evaluatedTitle ?? '', + sharePositionOrigin: sharePositionOrigin, + ); + + // Handle share result + if (result.status == ShareResultStatus.success) { + debugPrint('Share completed successfully: ${result.raw}'); + } else { + debugPrint('Share completed with status: ${result.status}'); + } + return; + } catch (e) { + debugPrint('Error sharing files: $e'); + if (kIsWeb) { + // On web, fall back to sharing just the text + await Share.share( + evaluatedText, + subject: evaluatedTitle ?? '', + sharePositionOrigin: sharePositionOrigin, + ); + return; + } + rethrow; + } + } + } + } + + // Fall back to sharing just text if no files or file sharing failed + if (evaluatedText.isNotEmpty) { + final result = await Share.share( + evaluatedText, + subject: evaluatedTitle ?? '', + sharePositionOrigin: sharePositionOrigin, + ); + + if (result.status == ShareResultStatus.success) { + debugPrint('Text share completed successfully: ${result.raw}'); + } else { + debugPrint('Text share completed with status: ${result.status}'); + } + } + } catch (e) { + debugPrint('ShareAction failed: $e'); + rethrow; + } } } 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 2e6fb4446..bdddff960 100644 --- a/modules/ensemble/lib/framework/action.dart +++ b/modules/ensemble/lib/framework/action.dart @@ -23,6 +23,7 @@ import 'package:ensemble/action/phone_contact_action.dart'; import 'package:ensemble/action/sign_in_out_action.dart'; import 'package:ensemble/action/sign_in_with_verification_code_actions.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'; @@ -1043,6 +1044,7 @@ enum ActionType { dispatchEvent, executeConditionalAction, executeActionGroup, + takeScreenshot, playAudio, stopAudio, pauseAudio, @@ -1169,6 +1171,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/lib/widget/input/form_textfield.dart b/modules/ensemble/lib/widget/input/form_textfield.dart index 099091d13..f6b0aa13f 100644 --- a/modules/ensemble/lib/widget/input/form_textfield.dart +++ b/modules/ensemble/lib/widget/input/form_textfield.dart @@ -119,7 +119,10 @@ abstract class BaseTextInput extends StatefulWidget @override Map getters() { var getters = _controller.textPlaceholderGetters; - getters.addAll({'value': () => textController.text ?? ''}); + getters.addAll({ + 'value': () => textController.text ?? '', + 'obscured': () => _controller.obscured, + }); return getters; } @@ -144,6 +147,8 @@ abstract class BaseTextInput extends StatefulWidget _controller.enableClearText = Utils.optionalBool(value), 'obscureToggle': (value) => _controller.obscureToggle = Utils.optionalBool(value), + 'obscured': (widget) => _controller.obscureText == true, + 'obscureTextWidget': (widget) => _controller.obscureTextWidget = widget, 'readOnly': (value) => _controller.readOnly = Utils.optionalBool(value), 'selectable': (value) => _controller.selectable = Utils.getBool(value, fallback: true), @@ -224,7 +229,9 @@ class TextInputController extends FormFieldController with HasTextPlaceholder { bool? obscureText; // applicable only for Password or obscure TextInput, to toggle between plain and secure text + bool? obscured; bool? obscureToggle; + dynamic obscureTextWidget; bool? readOnly; bool selectable = true; bool? toolbarDoneButton; @@ -256,7 +263,6 @@ class TextInputState extends FormFieldWidgetState bool didItChange = false; // password is obscure by default - late bool currentlyObscured; late List _inputFormatter; OverlayEntry? overlayEntry; @@ -303,7 +309,7 @@ class TextInputState extends FormFieldWidgetState @override void initState() { - currentlyObscured = + widget._controller.obscured = widget.isPassword() || widget._controller.obscureText == true; _inputFormatter = InputFormatter.getFormatter( widget._controller.inputType, widget._controller.mask); @@ -383,9 +389,10 @@ class TextInputState extends FormFieldWidgetState /// whether to show the content as plain text or obscure bool isObscureOrPlainText() { if (widget.isPassword()) { - return currentlyObscured; + return widget._controller.obscured ?? true; } else { - return widget._controller.obscureText == true && currentlyObscured; + return widget._controller.obscureText == true && + (widget._controller.obscured ?? true); } } @@ -406,19 +413,30 @@ class TextInputState extends FormFieldWidgetState // for password, show the toggle plain text/obscure text if ((widget.isPassword() || widget._controller.obscureText == true) && widget._controller.obscureToggle == true) { + void toggleObscured() { + bool newObscuredValue = !(widget._controller.obscured ?? true); + widget._controller.obscured = newObscuredValue; + widget.setProperty('obscured', newObscuredValue); + setState(() {}); + } + decoration = decoration.copyWith( - suffixIcon: IconButton( - icon: Icon( - currentlyObscured ? Icons.visibility : Icons.visibility_off, - size: ThemeManager().getInputIconSize(context).toDouble(), - color: ThemeManager().getInputIconColor(context), - ), - onPressed: () { - setState(() { - currentlyObscured = !currentlyObscured; - }); - }, - )); + suffixIcon: widget._controller.obscureTextWidget != null + ? GestureDetector( + onTap: toggleObscured, + child: scopeManager!.buildWidgetFromDefinition( + widget._controller.obscureTextWidget), + ) + : IconButton( + icon: Icon( + widget._controller.obscured ?? true + ? Icons.visibility + : Icons.visibility_off, + size: ThemeManager().getInputIconSize(context).toDouble(), + color: ThemeManager().getInputIconColor(context), + ), + onPressed: toggleObscured, + )); } else if (!widget.isPassword() && widget.textController.text.isNotEmpty && widget._controller.enableClearText == true) { 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: