mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 14:08:40 +00:00
602 lines
19 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|