diff --git a/modules/camera/lib/camera.dart b/modules/camera/lib/camera.dart index a4f03e21d..f5ec79644 100644 --- a/modules/camera/lib/camera.dart +++ b/modules/camera/lib/camera.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:image/image.dart' as img; +import 'dart:io' as io; +import 'helper/web.dart' as web; import 'package:camera/camera.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:ensemble/action/toast_actions.dart'; import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/widget/icon.dart' as iconframework; import 'package:ensemble/framework/widget/toast.dart'; import 'package:ensemble/framework/widget/widget.dart'; @@ -16,9 +17,12 @@ import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; import 'package:ensemble/framework/model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:geolocator/geolocator.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:sensors_plus/sensors_plus.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:video_player/video_player.dart'; enum CameraMode { photo, video, both } @@ -120,6 +124,8 @@ class Camera extends StatefulWidget _controller.autoCaptureInterval = Utils.getInt(value, fallback: -1), 'enableMicrophone': (value) => _controller.enableMicrophone = Utils.getBool(value, fallback: true), + 'captureOverlay': (value) => + _controller.captureOverlay = Utils.getBool(value, fallback: false), }; } } @@ -153,6 +159,7 @@ class MyCameraController extends WidgetController { IconModel? cameraRotateIcon; IconModel? focusIcon; bool enableMicrophone = true; + bool captureOverlay = false; int autoCaptureInterval = -1; ValueNotifier intervalCountdown = ValueNotifier(-1); @@ -219,6 +226,9 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { GeolocatorPlatform locator = GeolocatorPlatform.instance; + final GlobalKey _cameraPreviewKey = GlobalKey(); + final GlobalKey _overlayKey = GlobalKey(); + late int currentIndex; @override void initState() { @@ -461,27 +471,39 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { children: [ kIsWeb ? Center( - child: AspectRatio( - aspectRatio: - widget.controller.cameraController!.value.aspectRatio, - child: widget.controller.cameraController!.buildPreview(), + child: RepaintBoundary( + key: _cameraPreviewKey, + child: AspectRatio( + aspectRatio: + widget.controller.cameraController!.value.aspectRatio, + child: widget.controller.cameraController!.buildPreview(), + ), ), ) - : CameraPreview( - widget._controller.cameraController!, - child: LayoutBuilder(builder: (context, constraints) { - return GestureDetector( - onTapUp: kIsWeb - ? null - : (details) => onViewFinderTap(details, constraints), - onScaleUpdate: (details) async { - final zoom = details.scale.clamp(1.0, 2.0); - widget._controller.cameraController?.setZoomLevel(zoom); - }, - ); - }), + : RepaintBoundary( + key: _cameraPreviewKey, + child: CameraPreview( + widget._controller.cameraController!, + child: LayoutBuilder(builder: (context, constraints) { + return GestureDetector( + onTapUp: (details) => + onViewFinderTap(details, constraints), + onScaleUpdate: (details) async { + final zoom = details.scale.clamp(1.0, 2.0); + widget._controller.cameraController?.setZoomLevel(zoom); + }, + ); + }), + ), ), - if (widget.overlayWidget != null) Positioned.fill(child: widget.overlayWidget!), + if (widget.overlayWidget != null) + Align( + alignment: Alignment.center, + child: KeyedSubtree( + key: _overlayKey, + child: widget.overlayWidget!, + ), + ), imagePreviewButton(), Align( alignment: Alignment.bottomCenter, @@ -679,7 +701,15 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { }); }, deleteButtonAction: deleteImages, - ) + onShareButtonAction: () async { + try { + final file = + widget._controller.files.elementAt(currentIndex); + await Share.shareXFiles([XFile('${file.path}')]); + } on Exception catch (e) { + widget.onError?.call(e); + } + }) : const SizedBox.shrink(), Visibility( visible: isFullScreen, @@ -836,7 +866,8 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { Widget appbar( {required void Function()? backArrowAction, - required void Function()? deleteButtonAction}) { + required void Function()? deleteButtonAction, + required void Function()? onShareButtonAction}) { return Padding( padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 10.0), child: Row( @@ -850,14 +881,28 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { size: iconSize, ), shadowColor: Colors.black54), - buttons( - onPressed: deleteButtonAction, - icon: Icon( - Icons.delete_sharp, - color: iconColor, - size: iconSize, - ), - shadowColor: Colors.black54), + Row( + children: [ + if (!kIsWeb) + buttons( + onPressed: onShareButtonAction, + icon: Icon( + Icons.share, + color: iconColor, + size: iconSize, + ), + shadowColor: Colors.black54, + ), + buttons( + onPressed: deleteButtonAction, + icon: Icon( + Icons.delete_sharp, + color: iconColor, + size: iconSize, + ), + shadowColor: Colors.black54), + ], + ), ], ), ); @@ -975,7 +1020,15 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { await startVideoRecording(); } } else { - file = await takePicture(); + if (widget._controller.captureOverlay) { + try { + file = await takeOverlayCapture(); + } on Exception catch (e) { + print(e); + } + } else { + file = await takePicture(); + } } if (file == null) return; widget._controller.files.insert(0, file); @@ -994,6 +1047,82 @@ class CameraState extends EWidgetState with WidgetsBindingObserver { } } + Future takeOverlayCapture() async { + final overlayBox = + _overlayKey.currentContext?.findRenderObject() as RenderBox?; + if (overlayBox == null) { + widget.onError?.call('Error Capturing overlay: Overlay box not found'); + return null; + } + + final cameraBoundary = _cameraPreviewKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (cameraBoundary == null) { + widget.onError + ?.call('Error Capturing overlay: Camera boundary not found'); + return null; + } + + // Capture the image using camera controller + final xFile = await widget._controller.cameraController!.takePicture(); + final imageBytes = await xFile.readAsBytes(); + + final img.Image? decodedImage = img.decodeImage(imageBytes); + if (decodedImage == null) { + widget.onError + ?.call('Error Capturing overlay: Failed to decode image bytes'); + return null; + } + + // Calculate overlay position relative to camera preview + final cameraOffset = cameraBoundary.localToGlobal(Offset.zero); + final overlayOffset = overlayBox.localToGlobal(Offset.zero); + + final double left = overlayOffset.dx - cameraOffset.dx; + final double top = overlayOffset.dy - cameraOffset.dy; + + // Scale factors to map Flutter coordinates to image coordinates + final previewBox = cameraBoundary as RenderBox; + final scaleX = decodedImage.width / previewBox.size.width; + final scaleY = decodedImage.height / previewBox.size.height; + + // Calculate crop dimensions in image coordinates + final scaledLeft = (left * scaleX).floor().clamp(0, decodedImage.width); + final scaledTop = (top * scaleY).floor().clamp(0, decodedImage.height); + final scaledWidth = (overlayBox.size.width * scaleX) + .floor() + .clamp(0, decodedImage.width - scaledLeft); + final scaledHeight = (overlayBox.size.height * scaleY) + .floor() + .clamp(0, decodedImage.height - scaledTop); + + // Crop the image + final cropped = img.copyCrop( + decodedImage, + x: scaledLeft, + y: scaledTop, + width: scaledWidth, + height: scaledHeight, + ); + + final Uint8List croppedPng = Uint8List.fromList(img.encodePng(cropped)); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filename = 'overlay_image_$timestamp.png'; + + String? path; + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + final tempFile = io.File('${tempDir.path}/$filename'); + await tempFile.writeAsBytes(croppedPng); + path = tempFile.path; + } else { + final blob = web.Blob([croppedPng], 'image/png'); + path = web.Url.createObjectUrlFromBlob(blob); + } + + return File(filename, 'png', null, path, croppedPng); + } + bool canCapture() { if (!(widget._controller.maxCount != null && (widget._controller.files.length + 1) > widget._controller.maxCount!)) { diff --git a/modules/camera/lib/camera_manager.dart b/modules/camera/lib/camera_manager.dart index a97a840c8..fbbaf0cc0 100644 --- a/modules/camera/lib/camera_manager.dart +++ b/modules/camera/lib/camera_manager.dart @@ -37,7 +37,8 @@ const _optionMappings = { 'minCountMessage': 'minCountMessage', 'autoCaptureInterval': 'autoCaptureInterval', 'enableMicrophone': 'enableMicrophone', - 'instantPreview': 'instantPreview' + 'instantPreview': 'instantPreview', + 'captureOverlay': 'captureOverlay', }; const _angleAssistOptions = { @@ -149,7 +150,10 @@ class CameraManagerImpl extends CameraManager { Future bespokeCamera(BuildContext context, ShowCameraAction cameraAction, ScopeManager? scopeManager) async { - Widget? overlayWidget = buildOverlayWidget(scopeManager, cameraAction); + Widget? overlayWidget; + if (cameraAction.overlayWidget != null) { + overlayWidget = buildOverlayWidget(scopeManager, cameraAction); + } Camera camera = Camera( overlayWidget: overlayWidget, diff --git a/modules/camera/lib/helper/stub.dart b/modules/camera/lib/helper/stub.dart new file mode 100644 index 000000000..993845a4e --- /dev/null +++ b/modules/camera/lib/helper/stub.dart @@ -0,0 +1,14 @@ +// Stub implementation for non-web platforms +library web; + +class Blob { + Blob(List array, String type) { + throw UnsupportedError('Blob is only supported on web platforms'); + } +} + +class Url { + static String createObjectUrlFromBlob(dynamic blob) { + throw UnsupportedError('URL.createObjectURL is only supported on web platforms'); + } +} diff --git a/modules/camera/lib/helper/web.dart b/modules/camera/lib/helper/web.dart new file mode 100644 index 000000000..0c3a3d160 --- /dev/null +++ b/modules/camera/lib/helper/web.dart @@ -0,0 +1,14 @@ +import 'stub.dart' if (dart.library.html) 'dart:html' as html; + +class Blob { + final dynamic _blob; + + Blob(List array, String type) + : _blob = html.Blob(array, type); +} + +class Url { + static String createObjectUrlFromBlob(Blob blob) { + return html.Url.createObjectUrlFromBlob(blob._blob); + } +} diff --git a/modules/camera/pubspec.yaml b/modules/camera/pubspec.yaml index 50ba52cd6..d42079029 100644 --- a/modules/camera/pubspec.yaml +++ b/modules/camera/pubspec.yaml @@ -29,6 +29,8 @@ dependencies: sensors_plus: ^3.0.0 video_player: ^2.6.1 qr_code_scanner: ^1.0.1 + path_provider: ^2.1.5 + share_plus: ^10.1.4 dev_dependencies: flutter_test: