import 'dart:collection'; import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/model/protobuf/api/error.pb.dart' show ErrorCode; import 'package:twonly/src/services/api/media_send.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/views/camera/camera_preview_components/save_to_gallery.dart'; import 'package:twonly/src/views/camera/image_editor/action_button.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/components/notification_badge.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/camera/share_image_view.dart'; import 'package:flutter/services.dart'; import 'package:twonly/src/views/camera/image_editor/data/image_item.dart'; import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; import 'package:twonly/src/views/camera/image_editor/layers_viewer.dart'; import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart'; import 'package:screenshot/screenshot.dart'; import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; import 'package:video_player/video_player.dart'; List layers = []; List undoLayers = []; List removedLayers = []; const gMediaShowInfinite = 999999; class ShareImageEditorView extends StatefulWidget { const ShareImageEditorView({ super.key, this.imageBytes, this.sendTo, this.videoFilePath, required this.mirrorVideo, required this.useHighQuality, }); final Future? imageBytes; final File? videoFilePath; final Contact? sendTo; final bool mirrorVideo; final bool useHighQuality; @override State createState() => _ShareImageEditorView(); } class _ShareImageEditorView extends State { bool _isRealTwonly = false; int maxShowTime = gMediaShowInfinite; String? sendNextMediaToUserName; double tabDownPosition = 0; bool sendingOrLoadingImage = true; bool loadingImage = true; bool isDisposed = false; HashSet selectedUserIds = HashSet(); double widthRatio = 1, heightRatio = 1, pixelRatio = 1; VideoPlayerController? videoController; ImageItem currentImage = ImageItem(); ScreenshotController screenshotController = ScreenshotController(); /// Media upload variables int? mediaUploadId; Future? videoUploadHandler; @override void initState() { super.initState(); initAsync(); initMediaFileUpload(); layers.add(FilterLayerData()); if (widget.imageBytes != null) { loadImage(widget.imageBytes!); } else if (widget.videoFilePath != null) { setState(() { sendingOrLoadingImage = false; loadingImage = false; }); videoController = VideoPlayerController.file(widget.videoFilePath!); videoController?.setLooping(true); videoController?.initialize().then((_) { videoController!.play(); setState(() {}); }).catchError((Object error) { Log.error(error); }); } } void initAsync() async { final user = await getUser(); if (user == null) return; if (user.defaultShowTime != null) { setState(() { maxShowTime = user.defaultShowTime!; }); } } Future initMediaFileUpload() async { // media init was already called... if (mediaUploadId != null) return; mediaUploadId = await initMediaUpload(); if (widget.videoFilePath != null && mediaUploadId != null) { // start with the video compression... videoUploadHandler = addVideoToUpload(mediaUploadId!, widget.videoFilePath!); } } @override void dispose() { isDisposed = true; layers.clear(); videoController?.dispose(); super.dispose(); } void updateStatus(int userId, bool checked) { if (checked) { if (_isRealTwonly) { selectedUserIds.clear(); } selectedUserIds.add(userId); } else { selectedUserIds.remove(userId); } setState(() {}); } Future updateAsync(int userId) async { if (sendNextMediaToUserName != null) return; Contact? contact = await twonlyDB.contactsDao.getContactByUserId(userId).getSingleOrNull(); if (contact != null) { sendNextMediaToUserName = getContactDisplayName(contact); } } List get actionsAtTheRight { if (layers.isNotEmpty && layers.last.isEditing && layers.last.hasCustomActionButtons) { return []; } return [ ActionButton( Icons.text_fields_rounded, tooltipText: context.lang.addTextItem, onPressed: () async { layers = layers.where((x) => !x.isDeleted).toList(); if (layers.any((x) => x.isEditing)) return; undoLayers.clear(); removedLayers.clear(); layers.add(TextLayerData( textLayersBefore: layers.whereType().length, )); setState(() {}); }, ), const SizedBox(height: 8), ActionButton( Icons.draw_rounded, tooltipText: context.lang.addDrawing, onPressed: () async { undoLayers.clear(); removedLayers.clear(); layers.add(DrawLayerData()); setState(() {}); }, ), const SizedBox(height: 8), ActionButton( Icons.add_reaction_outlined, tooltipText: context.lang.addEmoji, onPressed: () async { EmojiLayerData? layer = await showModalBottomSheet( context: context, backgroundColor: Colors.black, builder: (BuildContext context) { return const Emojis(); }, ); if (layer == null) return; undoLayers.clear(); removedLayers.clear(); layers.add(layer); setState(() {}); }, ), const SizedBox(height: 8), NotificationBadge( count: (widget.videoFilePath != null) ? "0" : maxShowTime == 999999 ? "∞" : maxShowTime.toString(), child: ActionButton( (widget.videoFilePath != null) ? maxShowTime == 999999 ? Icons.repeat_rounded : Icons.repeat_one_rounded : Icons.timer_outlined, tooltipText: context.lang.protectAsARealTwonly, onPressed: () async { if (widget.videoFilePath != null) { setState(() { if (maxShowTime == gMediaShowInfinite) { maxShowTime = 0; } else { maxShowTime = gMediaShowInfinite; } }); return; } if (maxShowTime == gMediaShowInfinite) { maxShowTime = 1; } else if (maxShowTime == 1) { maxShowTime = 5; } else if (maxShowTime == 5) { maxShowTime = 20; } else { maxShowTime = gMediaShowInfinite; } setState(() {}); var user = await getUser(); if (user != null) { user.defaultShowTime = maxShowTime; updateUser(user); } }, ), ), const SizedBox(height: 8), ActionButton( FontAwesomeIcons.shieldHeart, tooltipText: context.lang.protectAsARealTwonly, color: _isRealTwonly ? Theme.of(context).colorScheme.primary : Colors.white, onPressed: () async { if (widget.sendTo != null) { if (!widget.sendTo!.verified) { showAlertDialog(context, context.lang.shareImageUserNotVerified, context.lang.shareImageUserNotVerifiedDesc); return; } } _isRealTwonly = !_isRealTwonly; if (_isRealTwonly) { maxShowTime = 12; } selectedUserIds = HashSet(); setState(() {}); }, ), ]; } List get actionsAtTheTop { if (layers.isNotEmpty && layers.last.isEditing && layers.last.hasCustomActionButtons) { return []; } return [ ActionButton( FontAwesomeIcons.xmark, tooltipText: context.lang.close, onPressed: () async { Navigator.pop(context, false); }, ), Expanded(child: Container()), const SizedBox(width: 8), ActionButton( FontAwesomeIcons.rotateLeft, tooltipText: context.lang.undo, disable: layers.where((x) => !x.isDeleted).length <= 2, onPressed: () { if (removedLayers.isNotEmpty) { var lastLayer = removedLayers.removeLast(); lastLayer.isDeleted = false; lastLayer.isEditing = false; layers.add(lastLayer); setState(() {}); return; } layers = layers.where((x) => !x.isDeleted).toList(); if (layers.length <= 2) { // do not remove image layer and filter layer return; } undoLayers.add(layers.removeLast()); setState(() {}); }, ), const SizedBox(width: 8), ActionButton( FontAwesomeIcons.rotateRight, tooltipText: context.lang.redo, disable: undoLayers.isEmpty, onPressed: () { if (undoLayers.isEmpty) return; layers.add(undoLayers.removeLast()); setState(() {}); }, ), const SizedBox(width: 70) ]; } Future pushShareImageView() async { if (mediaUploadId == null) { await initMediaFileUpload(); if (mediaUploadId == null) return; } Future imageBytes = getMergedImage(); videoController?.pause(); if (isDisposed || !mounted) return; bool? wasSend = await Navigator.push( context, MaterialPageRoute( builder: (context) => ShareImageView( imageBytesFuture: imageBytes, isRealTwonly: _isRealTwonly, maxShowTime: maxShowTime, selectedUserIds: selectedUserIds, updateStatus: updateStatus, videoUploadHandler: videoUploadHandler, mediaUploadId: mediaUploadId!, mirrorVideo: widget.mirrorVideo, ), ), ); if (wasSend != null && wasSend && mounted) { // ignore: use_build_context_synchronously Navigator.pop(context, true); } else { videoController?.play(); } } Future getMergedImage() async { Uint8List? image; if (layers.length > 1 || widget.videoFilePath != null) { for (var x in layers) { x.showCustomButtons = false; } setState(() {}); image = await screenshotController.capture( pixelRatio: (widget.useHighQuality) ? pixelRatio : 1); for (var x in layers) { x.showCustomButtons = true; } setState(() {}); } else if (layers.length == 1) { if (layers.first is BackgroundLayerData) { image = (layers.first as BackgroundLayerData).image.bytes; } } return image; } Future loadImage(Future imageFile) async { Uint8List? imageBytes = await imageFile; await currentImage.load(imageBytes); if (isDisposed) return; if (!context.mounted) return; layers.insert( 0, BackgroundLayerData( image: currentImage, ), ); setState(() { sendingOrLoadingImage = false; loadingImage = false; }); } Future sendImageToSinglePerson() async { if (sendingOrLoadingImage) return; setState(() { sendingOrLoadingImage = true; }); Uint8List? imageBytes = await getMergedImage(); if (!context.mounted) return; if (imageBytes == null) { // ignore: use_build_context_synchronously Navigator.pop(context, true); return; } ErrorCode? err = await isAllowedToSend(); if (!context.mounted) return; if (err != null) { setState(() { sendingOrLoadingImage = false; }); if (mounted) { await Navigator.push(context, MaterialPageRoute(builder: (context) { return SubscriptionView( redirectError: err, ); })); } } else { Future imageHandler = addOrModifyImageToUpload(mediaUploadId!, imageBytes); // first finalize the upload await finalizeUpload( mediaUploadId!, [widget.sendTo!.userId], _isRealTwonly, widget.videoFilePath != null, widget.mirrorVideo, maxShowTime, ); /// then call the upload process in the background encryptAndPreUploadMediaFiles( mediaUploadId!, imageHandler, videoUploadHandler, ); if (context.mounted) { // ignore: use_build_context_synchronously Navigator.pop(context, true); } } } @override Widget build(BuildContext context) { pixelRatio = MediaQuery.of(context).devicePixelRatio; if (widget.sendTo != null) { sendNextMediaToUserName = getContactDisplayName(widget.sendTo!); } return Scaffold( backgroundColor: Colors.white.withAlpha(0), resizeToAvoidBottomInset: false, body: Stack( fit: StackFit.expand, children: [ GestureDetector( onTapDown: (details) { if (details.globalPosition.dy > 60) { tabDownPosition = details.globalPosition.dy - 60; } else { tabDownPosition = details.globalPosition.dy; } }, onTap: () { if (layers.any((x) => x.isEditing)) { return; } layers = layers.where((x) => !x.isDeleted).toList(); undoLayers.clear(); removedLayers.clear(); layers.add(TextLayerData( offset: Offset(0, tabDownPosition), textLayersBefore: layers.whereType().length, )); setState(() {}); }, child: MediaViewSizing( child: SizedBox( height: currentImage.height / pixelRatio, width: currentImage.width / pixelRatio, child: Stack( children: [ if (videoController != null) Positioned.fill( child: Transform.flip( flipX: widget.mirrorVideo, child: VideoPlayer(videoController!), ), ), Screenshot( controller: screenshotController, child: LayersViewer( layers: layers.where((x) => !x.isDeleted).toList(), onUpdate: () { for (final layer in layers) { layer.isEditing = false; if (layer.isDeleted) { removedLayers.add(layer); } } layers = layers.where((x) => !x.isDeleted).toList(); setState(() {}); }, ), ), ], ), ), ), ), Positioned( top: 10, left: 5, right: 0, child: SafeArea( child: Row( children: actionsAtTheTop, ), ), ), Positioned( right: 6, top: 100, child: Container( alignment: Alignment.bottomCenter, padding: const EdgeInsets.symmetric(vertical: 16), child: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: actionsAtTheRight, ), ), ), ), ], ), bottomNavigationBar: Container( color: Theme.of(context).colorScheme.surface, child: SafeArea( child: Padding( padding: const EdgeInsets.only(bottom: 20), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SaveToGalleryButton( getMergedImage: getMergedImage, mediaUploadId: mediaUploadId, videoFilePath: widget.videoFilePath, sendNextMediaToUserName: sendNextMediaToUserName, isLoading: loadingImage, ), if (sendNextMediaToUserName != null) SizedBox(width: 10), if (sendNextMediaToUserName != null) OutlinedButton( style: OutlinedButton.styleFrom( iconColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.primary, ), onPressed: pushShareImageView, child: FaIcon(FontAwesomeIcons.userPlus), ), SizedBox(width: sendNextMediaToUserName == null ? 20 : 10), FilledButton.icon( icon: sendingOrLoadingImage ? SizedBox( height: 12, width: 12, child: CircularProgressIndicator( strokeWidth: 2, color: Theme.of(context).colorScheme.inversePrimary, ), ) : FaIcon(FontAwesomeIcons.solidPaperPlane), onPressed: () async { if (sendingOrLoadingImage) return; if (widget.sendTo == null) return pushShareImageView(); sendImageToSinglePerson(); }, style: ButtonStyle( padding: WidgetStateProperty.all( EdgeInsets.symmetric(vertical: 10, horizontal: 30), ), ), label: Text( (sendNextMediaToUserName == null) ? context.lang.shareImagedEditorShareWith : sendNextMediaToUserName!, style: TextStyle(fontSize: 17), ), ), ], ), ), ), ), ); } }