twonly-app/lib/src/views/camera/share_image_editor_view.dart
2025-06-06 10:20:17 +02:00

602 lines
19 KiB
Dart

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<Layer> layers = [];
List<Layer> undoLayers = [];
List<Layer> 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<Uint8List?>? imageBytes;
final File? videoFilePath;
final Contact? sendTo;
final bool mirrorVideo;
final bool useHighQuality;
@override
State<ShareImageEditorView> createState() => _ShareImageEditorView();
}
class _ShareImageEditorView extends State<ShareImageEditorView> {
bool _isRealTwonly = false;
int maxShowTime = gMediaShowInfinite;
String? sendNextMediaToUserName;
double tabDownPosition = 0;
bool sendingOrLoadingImage = true;
bool loadingImage = true;
bool isDisposed = false;
HashSet<int> selectedUserIds = HashSet();
double widthRatio = 1, heightRatio = 1, pixelRatio = 1;
VideoPlayerController? videoController;
ImageItem currentImage = ImageItem();
ScreenshotController screenshotController = ScreenshotController();
/// Media upload variables
int? mediaUploadId;
Future<bool>? 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<Widget> get actionsAtTheRight {
if (layers.isNotEmpty &&
layers.last.isEditing &&
layers.last.hasCustomActionButtons) {
return [];
}
return <Widget>[
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<TextLayerData>().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<Widget> 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<Uint8List?> 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<Uint8List?> 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<void> loadImage(Future<Uint8List?> 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<TextLayerData>().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>(
EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
),
label: Text(
(sendNextMediaToUserName == null)
? context.lang.shareImagedEditorShareWith
: sendNextMediaToUserName!,
style: TextStyle(fontSize: 17),
),
),
],
),
),
),
),
);
}
}