import 'dart:async'; import 'dart:io'; import 'package:camera/camera.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_android_volume_keydown/flutter_android_volume_keydown.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:screenshot/screenshot.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/mediafiles/upload.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/camera/camera_preview_components/permissions_view.dart'; import 'package:twonly/src/views/camera/camera_preview_components/send_to.dart'; import 'package:twonly/src/views/camera/camera_preview_components/video_recording_time.dart'; import 'package:twonly/src/views/camera/camera_preview_components/zoom_selector.dart'; import 'package:twonly/src/views/camera/image_editor/action_button.dart'; import 'package:twonly/src/views/camera/share_image_editor_view.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/home.view.dart'; int maxVideoRecordingTime = 60; Future<(SelectedCameraDetails, CameraController)?> initializeCameraController( SelectedCameraDetails details, int sCameraId, bool init, ) async { var cameraId = sCameraId; if (cameraId >= gCameras.length) return null; if (init) { for (; cameraId < gCameras.length; cameraId++) { if (gCameras[cameraId].lensDirection == CameraLensDirection.back) { break; } } } details.isZoomAble = false; if (details.cameraId != cameraId) { // switch between front and back details.scaleFactor = 1; } final cameraController = CameraController( gCameras[cameraId], ResolutionPreset.high, enableAudio: await Permission.microphone.isGranted, ); await cameraController.initialize().then((_) async { await cameraController.setZoomLevel(details.scaleFactor); await cameraController.lockCaptureOrientation(DeviceOrientation.portraitUp); await cameraController .setFlashMode(details.isFlashOn ? FlashMode.always : FlashMode.off); await cameraController .getMaxZoomLevel() .then((double value) => details.maxAvailableZoom = value); await cameraController .getMinZoomLevel() .then((double value) => details.minAvailableZoom = value); details ..isZoomAble = details.maxAvailableZoom != details.minAvailableZoom ..cameraLoaded = true ..cameraId = cameraId; }).catchError((Object e) { Log.error('$e'); }); return (details, cameraController); } class SelectedCameraDetails { double maxAvailableZoom = 1; double minAvailableZoom = 1; int cameraId = 0; bool isZoomAble = false; bool isFlashOn = false; double scaleFactor = 1; bool cameraLoaded = false; } class CameraPreviewControllerView extends StatelessWidget { const CameraPreviewControllerView({ required this.cameraController, required this.selectCamera, required this.selectedCameraDetails, required this.screenshotController, required this.isVisible, super.key, this.sendToGroup, }); final Group? sendToGroup; final Future Function(int sCameraId, bool init) selectCamera; final CameraController? cameraController; final SelectedCameraDetails selectedCameraDetails; final ScreenshotController screenshotController; final bool isVisible; @override Widget build(BuildContext context) { return FutureBuilder( future: checkPermissions(), builder: (context, snap) { if (snap.hasData) { if (snap.data!) { return CameraPreviewView( sendToGroup: sendToGroup, selectCamera: selectCamera, cameraController: cameraController, selectedCameraDetails: selectedCameraDetails, screenshotController: screenshotController, isVisible: isVisible, ); } else { return PermissionHandlerView( onSuccess: () { selectCamera(0, true); }, ); } } else { return Container(); } }, ); } } class CameraPreviewView extends StatefulWidget { const CameraPreviewView({ required this.selectCamera, required this.cameraController, required this.selectedCameraDetails, required this.screenshotController, required this.isVisible, super.key, this.sendToGroup, }); final Group? sendToGroup; final Future Function( int sCameraId, bool init, ) selectCamera; final CameraController? cameraController; final SelectedCameraDetails selectedCameraDetails; final ScreenshotController screenshotController; final bool isVisible; @override State createState() => _CameraPreviewViewState(); } class _CameraPreviewViewState extends State { bool _sharePreviewIsShown = false; bool _galleryLoadedImageIsShown = false; bool _showSelfieFlash = false; double _basePanY = 0; double _baseScaleFactor = 0; bool _isVideoRecording = false; bool _hasAudioPermission = true; DateTime? _videoRecordingStarted; Timer? _videoRecordingTimer; DateTime _currentTime = DateTime.now(); final GlobalKey keyTriggerButton = GlobalKey(); final GlobalKey navigatorKey = GlobalKey(); StreamSubscription? androidVolumeDownSub; @override void initState() { super.initState(); initVolumeControl(); initAsync(); } @override void didUpdateWidget(covariant CameraPreviewView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isVisible != widget.isVisible) { if (widget.isVisible) { initVolumeControl(); } else { deInitVolumeControl(); } } } @override void dispose() { _videoRecordingTimer?.cancel(); deInitVolumeControl(); super.dispose(); } Future initVolumeControl() async { if (Platform.isIOS) { await FlutterVolumeController.updateShowSystemUI(false); double? startedVolume; FlutterVolumeController.addListener( (volume) async { if (!widget.isVisible) { await deInitVolumeControl(); return; } if (startedVolume == null) { startedVolume = volume; return; } if (startedVolume == volume) { return; } // reset the volume back to the original value await FlutterVolumeController.setVolume(startedVolume!); await takePicture(); }, ); } if (Platform.isAndroid) { if ((await DeviceInfoPlugin().androidInfo).version.release == '9') { // MissingPluginException: MissingPluginException(No implementation found for method cancel on channel dart-tools.dev/flutter_… // Maybe this is the reason? return; } else { androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen((event) { if (widget.isVisible) { takePicture(); } else { deInitVolumeControl(); return; } }); } } } Future deInitVolumeControl() async { if (Platform.isIOS) { await FlutterVolumeController.updateShowSystemUI(true); FlutterVolumeController.removeListener(); } if (Platform.isAndroid) { await androidVolumeDownSub?.cancel(); } } Future initAsync() async { _hasAudioPermission = await Permission.microphone.isGranted; if (!_hasAudioPermission && !gUser.requestedAudioPermission) { await updateUserdata((u) { u.requestedAudioPermission = true; return u; }); await requestMicrophonePermission(); } if (!mounted) return; setState(() {}); } Future requestMicrophonePermission() async { final statuses = await [ Permission.microphone, ].request(); if (statuses[Permission.microphone]!.isPermanentlyDenied) { await openAppSettings(); } else { _hasAudioPermission = await Permission.microphone.isGranted; setState(() {}); } } Future updateScaleFactor(double newScale) async { if (widget.selectedCameraDetails.scaleFactor == newScale || widget.cameraController == null) { return; } await widget.cameraController?.setZoomLevel( newScale.clamp( widget.selectedCameraDetails.minAvailableZoom, widget.selectedCameraDetails.maxAvailableZoom, ), ); setState(() { widget.selectedCameraDetails.scaleFactor = newScale; }); } Future loadAndDeletePictureFromFile(XFile picture) async { try { // Load the image into bytes final imageBytes = await picture.readAsBytes(); // Remove the image file await File(picture.path).delete(); return imageBytes; } catch (e) { if (context.mounted) { // ignore: use_build_context_synchronously ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Error loading picture: $e'), duration: const Duration(seconds: 3), ), ); } return null; } } Future takePicture() async { if (_sharePreviewIsShown || _isVideoRecording) return; late Future imageBytes; setState(() { _sharePreviewIsShown = true; }); if (widget.selectedCameraDetails.isFlashOn) { if (isFront) { setState(() { _showSelfieFlash = true; }); } else { await widget.cameraController?.setFlashMode(FlashMode.torch); } await Future.delayed(const Duration(milliseconds: 1000)); } await widget.cameraController?.pausePreview(); if (!mounted) { return; } if (Platform.isIOS) { // android has a problem with this. Flash is turned off in the pausePreview function. await widget.cameraController?.setFlashMode(FlashMode.off); } if (!mounted) { return; } imageBytes = widget.screenshotController .capture(pixelRatio: MediaQuery.of(context).devicePixelRatio); if (await pushMediaEditor(imageBytes, null)) { return; } setState(() { _sharePreviewIsShown = false; }); } Future pushMediaEditor( Future? imageBytes, File? videoFilePath, { bool sharedFromGallery = false, MediaType? mediaType, }) async { final type = mediaType ?? ((videoFilePath != null) ? MediaType.video : MediaType.image); final mediaFileService = await initializeMediaUpload( type, gUser.defaultShowTime, isDraftMedia: true, ); if (!mounted) return true; if (mediaFileService == null) { Log.error('Could not generate media file service'); return false; } if (videoFilePath != null) { videoFilePath ..copySync(mediaFileService.originalPath.path) ..deleteSync(); // Start with compressing the video, to speed up the process in case the video is not changed. // unawaited(mediaFileService.compressMedia()); } await deInitVolumeControl(); if (!mounted) return true; final shouldReturn = await Navigator.push( context, PageRouteBuilder( opaque: false, pageBuilder: (context, a1, a2) => ShareImageEditorView( imageBytesFuture: imageBytes, sharedFromGallery: sharedFromGallery, sendToGroup: widget.sendToGroup, mediaFileService: mediaFileService, ), transitionsBuilder: (context, animation, secondaryAnimation, child) { return child; }, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, ), ) as bool?; if (mounted) { setState(() { _sharePreviewIsShown = false; _showSelfieFlash = false; }); } if (!mounted) return true; await initVolumeControl(); // shouldReturn is null when the user used the back button if (shouldReturn != null && shouldReturn) { if (widget.sendToGroup == null) { globalUpdateOfHomeViewPageIndex(0); } else if (mounted) { Navigator.pop(context); } return true; } await widget.selectCamera( widget.selectedCameraDetails.cameraId, false, ); return false; } bool get isFront => widget.cameraController?.description.lensDirection == CameraLensDirection.front; Future onPanUpdate(dynamic details) async { if (isFront || details == null) { return; } if (widget.cameraController == null || !widget.cameraController!.value.isInitialized) { return; } widget.selectedCameraDetails.scaleFactor = (_baseScaleFactor + // ignore: avoid_dynamic_calls (_basePanY - (details.localPosition.dy as double)) / 30) .clamp(1, widget.selectedCameraDetails.maxAvailableZoom); await widget.cameraController! .setZoomLevel(widget.selectedCameraDetails.scaleFactor); if (mounted) { setState(() {}); } } Future pickImageFromGallery() async { setState(() { _galleryLoadedImageIsShown = true; _sharePreviewIsShown = true; }); final picker = ImagePicker(); final pickedFile = await picker.pickMedia(); if (pickedFile != null) { final imageExtensions = [ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic', '.heif', '.avif', ]; Log.info('Picket from gallery: ${pickedFile.path}'); File? videoFilePath; Future? imageBytes; MediaType? mediaType; final isImage = imageExtensions.any((ext) => pickedFile.name.contains(ext)); if (isImage) { if (pickedFile.name.contains('.gif')) { mediaType = MediaType.gif; } imageBytes = pickedFile.readAsBytes(); } else { videoFilePath = File(pickedFile.path); } await pushMediaEditor( imageBytes, videoFilePath, sharedFromGallery: true, mediaType: mediaType, ); } setState(() { _galleryLoadedImageIsShown = false; _sharePreviewIsShown = false; }); } Future startVideoRecording() async { if (widget.cameraController != null && widget.cameraController!.value.isRecordingVideo) { return; } setState(() { _isVideoRecording = true; }); try { await widget.cameraController?.startVideoRecording(); _videoRecordingTimer = Timer.periodic(const Duration(milliseconds: 15), (timer) { setState(() { _currentTime = DateTime.now(); }); if (_videoRecordingStarted != null && _currentTime.difference(_videoRecordingStarted!).inSeconds >= maxVideoRecordingTime) { timer.cancel(); _videoRecordingTimer = null; stopVideoRecording(); } }); setState(() { _videoRecordingStarted = DateTime.now(); _isVideoRecording = true; }); } on CameraException catch (e) { setState(() { _isVideoRecording = false; }); _showCameraException(e); return; } } Future stopVideoRecording() async { if (_videoRecordingTimer != null) { _videoRecordingTimer?.cancel(); _videoRecordingTimer = null; } setState(() { _videoRecordingStarted = null; _isVideoRecording = false; }); if (widget.cameraController == null || !widget.cameraController!.value.isRecordingVideo) { return; } setState(() { _sharePreviewIsShown = true; }); try { final videoPath = await widget.cameraController?.stopVideoRecording(); if (videoPath == null) return; await widget.cameraController?.pausePreview(); if (await pushMediaEditor(null, File(videoPath.path))) { return; } } on CameraException catch (e) { _showCameraException(e); return; } } void _showCameraException(dynamic e) { Log.error('$e'); try { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Error: $e'), duration: const Duration(seconds: 3), ), ); } // ignore: empty_catches } catch (e) {} } @override Widget build(BuildContext context) { if (widget.selectedCameraDetails.cameraId >= gCameras.length || widget.cameraController == null) { return Container(); } return MediaViewSizing( requiredHeight: 80, bottomNavigation: Container(), child: GestureDetector( onPanStart: (details) async { if (isFront) { return; } setState(() { _basePanY = details.localPosition.dy; _baseScaleFactor = widget.selectedCameraDetails.scaleFactor; }); }, onLongPressMoveUpdate: onPanUpdate, onLongPressStart: (details) { setState(() { _basePanY = details.localPosition.dy; _baseScaleFactor = widget.selectedCameraDetails.scaleFactor; }); // Get the position of the pointer final renderBox = keyTriggerButton.currentContext!.findRenderObject()! as RenderBox; final localPosition = renderBox.globalToLocal(details.globalPosition); final containerRect = Rect.fromLTWH(0, 0, renderBox.size.width, renderBox.size.height); if (containerRect.contains(localPosition)) { startVideoRecording(); } }, onLongPressEnd: (a) { stopVideoRecording(); }, onPanEnd: (a) { stopVideoRecording(); }, onPanUpdate: onPanUpdate, child: Stack( children: [ if (_galleryLoadedImageIsShown) Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 1, color: context.color.primary, ), ), ), if (!_sharePreviewIsShown && widget.sendToGroup != null && !_isVideoRecording) SendToWidget(sendTo: widget.sendToGroup!.groupName), if (!_sharePreviewIsShown && !_isVideoRecording) Positioned( right: 5, top: 0, child: Container( alignment: Alignment.bottomCenter, padding: const EdgeInsets.symmetric(vertical: 16), child: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ ActionButton( Icons.repeat_rounded, tooltipText: context.lang.switchFrontAndBackCamera, onPressed: () async { await widget.selectCamera( (widget.selectedCameraDetails.cameraId + 1) % 2, false, ); }, ), ActionButton( widget.selectedCameraDetails.isFlashOn ? Icons.flash_on_rounded : Icons.flash_off_rounded, tooltipText: context.lang.toggleFlashLight, color: widget.selectedCameraDetails.isFlashOn ? Colors.white : Colors.white.withAlpha(160), onPressed: () async { if (widget.selectedCameraDetails.isFlashOn) { await widget.cameraController ?.setFlashMode(FlashMode.off); widget.selectedCameraDetails.isFlashOn = false; } else { await widget.cameraController ?.setFlashMode(FlashMode.always); widget.selectedCameraDetails.isFlashOn = true; } setState(() {}); }, ), if (!_hasAudioPermission) ActionButton( Icons.mic_off_rounded, color: Colors.white.withAlpha(160), tooltipText: 'Allow microphone access for video recording.', onPressed: requestMicrophonePermission, ), ], ), ), ), ), if (!_sharePreviewIsShown) Positioned( bottom: 30, left: 0, right: 0, child: Align( alignment: Alignment.bottomCenter, child: Column( children: [ if (widget.cameraController!.value.isInitialized && widget.selectedCameraDetails.isZoomAble && !isFront && !_isVideoRecording) SizedBox( width: 120, child: CameraZoomButtons( key: widget.key, scaleFactor: widget.selectedCameraDetails.scaleFactor, updateScaleFactor: updateScaleFactor, selectCamera: widget.selectCamera, selectedCameraDetails: widget.selectedCameraDetails, controller: widget.cameraController!, ), ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (!_isVideoRecording) GestureDetector( onTap: pickImageFromGallery, child: Align( child: Container( height: 50, width: 80, padding: const EdgeInsets.all(2), child: const Center( child: FaIcon( FontAwesomeIcons.photoFilm, color: Colors.white, size: 25, ), ), ), ), ), GestureDetector( onTap: takePicture, // onLongPress: startVideoRecording, key: keyTriggerButton, child: Align( child: Container( height: 100, width: 100, clipBehavior: Clip.antiAliasWithSaveLayer, padding: const EdgeInsets.all(2), decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( width: 7, color: _isVideoRecording ? Colors.red : Colors.white, ), ), ), ), ), if (!_isVideoRecording) const SizedBox(width: 80), ], ), ], ), ), ), VideoRecordingTimer( videoRecordingStarted: _videoRecordingStarted, maxVideoRecordingTime: maxVideoRecordingTime, ), if (!_sharePreviewIsShown && widget.sendToGroup != null) Positioned( left: 5, top: 10, child: ActionButton( FontAwesomeIcons.xmark, tooltipText: context.lang.close, onPressed: () async { Navigator.pop(context); }, ), ), if (_showSelfieFlash) Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(22), child: Container( color: Colors.white, ), ), ), ], ), ), ); } }