mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
improve image editor
This commit is contained in:
parent
7b5ca07e2f
commit
3efaea3175
22 changed files with 1229 additions and 78 deletions
|
|
@ -36,6 +36,14 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
Expression<bool> get _visibleInChat =>
|
Expression<bool> get _visibleInChat =>
|
||||||
messages.isStory.equals(false) | messages.mediaStored.equals(true);
|
messages.isStory.equals(false) | messages.mediaStored.equals(true);
|
||||||
|
|
||||||
|
/// Media rows whose file reference has already been removed cannot be
|
||||||
|
/// previewed or opened. Keep them out of chat summaries unless they are a
|
||||||
|
/// deletion tombstone, which is still useful to show in the conversation.
|
||||||
|
Expression<bool> get _hasPreviewableContent =>
|
||||||
|
messages.type.equals(MessageType.media.name).not() |
|
||||||
|
mediaFiles.mediaId.isNotNull() |
|
||||||
|
messages.isDeletedFromSender.equals(true);
|
||||||
|
|
||||||
Stream<List<Message>> watchMessageNotOpened(String groupId) {
|
Stream<List<Message>> watchMessageNotOpened(String groupId) {
|
||||||
final query =
|
final query =
|
||||||
select(messages).join([
|
select(messages).join([
|
||||||
|
|
@ -48,6 +56,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
messages.openedAt.isNull() &
|
messages.openedAt.isNull() &
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
_visibleInChat &
|
_visibleInChat &
|
||||||
|
_hasPreviewableContent &
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
messages.isDeletedFromSender.equals(false) &
|
messages.isDeletedFromSender.equals(false) &
|
||||||
(messages.mediaId.isNull() |
|
(messages.mediaId.isNull() |
|
||||||
|
|
@ -72,6 +81,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
messages.openedAt.isNull() &
|
messages.openedAt.isNull() &
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
_visibleInChat &
|
_visibleInChat &
|
||||||
|
_hasPreviewableContent &
|
||||||
messages.isDeletedFromSender.equals(false) &
|
messages.isDeletedFromSender.equals(false) &
|
||||||
(messages.mediaId.isNull() |
|
(messages.mediaId.isNull() |
|
||||||
mediaFiles.downloadState.isNull() |
|
mediaFiles.downloadState.isNull() |
|
||||||
|
|
@ -101,6 +111,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
messages.isWidgetMedia.equals(false) &
|
messages.isWidgetMedia.equals(false) &
|
||||||
_visibleInChat &
|
_visibleInChat &
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
|
messages.isDeletedFromSender.equals(false) &
|
||||||
messages.mediaId.isNotNull() &
|
messages.mediaId.isNotNull() &
|
||||||
messages.senderId.isNotNull() &
|
messages.senderId.isNotNull() &
|
||||||
messages.type.equals(MessageType.media.name),
|
messages.type.equals(MessageType.media.name),
|
||||||
|
|
@ -146,6 +157,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
..where(
|
..where(
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
_visibleInChat &
|
_visibleInChat &
|
||||||
|
_hasPreviewableContent &
|
||||||
// messages in groups will only be removed in case all members have received it...
|
// messages in groups will only be removed in case all members have received it...
|
||||||
// so ensuring that this message is not shown in the messages anymore
|
// so ensuring that this message is not shown in the messages anymore
|
||||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
|
|
@ -180,6 +192,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
INNER JOIN groups ON groups.group_id = messages.group_id
|
INNER JOIN groups ON groups.group_id = messages.group_id
|
||||||
LEFT JOIN media_files ON media_files.media_id = messages.media_id
|
LEFT JOIN media_files ON media_files.media_id = messages.media_id
|
||||||
WHERE (messages.is_story = 0 OR messages.media_stored = 1)
|
WHERE (messages.is_story = 0 OR messages.media_stored = 1)
|
||||||
|
AND (
|
||||||
|
messages.type != 'media' OR
|
||||||
|
media_files.media_id IS NOT NULL OR
|
||||||
|
messages.is_deleted_from_sender = 1
|
||||||
|
)
|
||||||
AND (
|
AND (
|
||||||
messages.opened_at IS NULL OR
|
messages.opened_at IS NULL OR
|
||||||
messages.media_stored = 1 OR
|
messages.media_stored = 1 OR
|
||||||
|
|
|
||||||
|
|
@ -1294,6 +1294,30 @@ abstract class AppLocalizations {
|
||||||
/// **'Emoji'**
|
/// **'Emoji'**
|
||||||
String get addEmoji;
|
String get addEmoji;
|
||||||
|
|
||||||
|
/// No description provided for @drawingColorPicker.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick color from image'**
|
||||||
|
String get drawingColorPicker;
|
||||||
|
|
||||||
|
/// No description provided for @createStickerFromImage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create sticker from image'**
|
||||||
|
String get createStickerFromImage;
|
||||||
|
|
||||||
|
/// No description provided for @stickerSelectionHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Move or resize the frame around the part you want as a sticker.'**
|
||||||
|
String get stickerSelectionHint;
|
||||||
|
|
||||||
|
/// No description provided for @stickerCreating.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Creating sticker…'**
|
||||||
|
String get stickerCreating;
|
||||||
|
|
||||||
/// No description provided for @toggleFlashLight.
|
/// No description provided for @toggleFlashLight.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,19 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get addEmoji => 'رمز تعبيري';
|
String get addEmoji => 'رمز تعبيري';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get drawingColorPicker => 'اختيار لون من الصورة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createStickerFromImage => 'إنشاء ملصق من الصورة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stickerSelectionHint =>
|
||||||
|
'حرّك الإطار أو غيّر حجمه حول الجزء الذي تريده كملصق.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stickerCreating => 'جارٍ إنشاء الملصق…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get toggleFlashLight => 'تشغيل الفلاش أو إيقافه';
|
String get toggleFlashLight => 'تشغيل الفلاش أو إيقافه';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get addEmoji => 'Emoji';
|
String get addEmoji => 'Emoji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get drawingColorPicker => 'Farbe aus dem Bild aufnehmen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createStickerFromImage => 'Sticker aus Bild erstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stickerSelectionHint =>
|
||||||
|
'Verschiebe oder vergrößere den Rahmen um den Bereich, der zum Sticker werden soll.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stickerCreating => 'Sticker wird erstellt…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get toggleFlashLight => 'Taschenlampe umschalten';
|
String get toggleFlashLight => 'Taschenlampe umschalten';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -680,6 +680,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get addEmoji => 'Emoji';
|
String get addEmoji => 'Emoji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get drawingColorPicker => 'Pick color from image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createStickerFromImage => 'Create sticker from image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stickerSelectionHint =>
|
||||||
|
'Move or resize the frame around the part you want as a sticker.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get stickerCreating => 'Creating sticker…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get toggleFlashLight => 'Toggle the flash light';
|
String get toggleFlashLight => 'Toggle the flash light';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,10 @@
|
||||||
"protectAsARealTwonly": "إرسال كرسالة twonly حقيقية!",
|
"protectAsARealTwonly": "إرسال كرسالة twonly حقيقية!",
|
||||||
"addDrawing": "رسم",
|
"addDrawing": "رسم",
|
||||||
"addEmoji": "رمز تعبيري",
|
"addEmoji": "رمز تعبيري",
|
||||||
|
"drawingColorPicker": "اختيار لون من الصورة",
|
||||||
|
"createStickerFromImage": "إنشاء ملصق من الصورة",
|
||||||
|
"stickerSelectionHint": "حرّك الإطار أو غيّر حجمه حول الجزء الذي تريده كملصق.",
|
||||||
|
"stickerCreating": "جارٍ إنشاء الملصق…",
|
||||||
"toggleFlashLight": "تشغيل الفلاش أو إيقافه",
|
"toggleFlashLight": "تشغيل الفلاش أو إيقافه",
|
||||||
"userFound": "تم العثور على {username}",
|
"userFound": "تم العثور على {username}",
|
||||||
"userFoundBody": "هل تريد التواصل مع {username}؟",
|
"userFoundBody": "هل تريد التواصل مع {username}؟",
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,10 @@
|
||||||
"protectAsARealTwonly": "Als echtes twonly senden!",
|
"protectAsARealTwonly": "Als echtes twonly senden!",
|
||||||
"addDrawing": "Zeichnung",
|
"addDrawing": "Zeichnung",
|
||||||
"addEmoji": "Emoji",
|
"addEmoji": "Emoji",
|
||||||
|
"drawingColorPicker": "Farbe aus dem Bild aufnehmen",
|
||||||
|
"createStickerFromImage": "Sticker aus Bild erstellen",
|
||||||
|
"stickerSelectionHint": "Verschiebe oder vergrößere den Rahmen um den Bereich, der zum Sticker werden soll.",
|
||||||
|
"stickerCreating": "Sticker wird erstellt…",
|
||||||
"toggleFlashLight": "Taschenlampe umschalten",
|
"toggleFlashLight": "Taschenlampe umschalten",
|
||||||
"errorInternalError": "Der Server ist derzeit nicht verfügbar. Bitte versuche es später erneut.",
|
"errorInternalError": "Der Server ist derzeit nicht verfügbar. Bitte versuche es später erneut.",
|
||||||
"errorInvalidInvitationCode": "Der von dir angegebene Einladungscode ist ungültig. Bitte überprüfe den Code und versuche es erneut.",
|
"errorInvalidInvitationCode": "Der von dir angegebene Einladungscode ist ungültig. Bitte überprüfe den Code und versuche es erneut.",
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,10 @@
|
||||||
"protectAsARealTwonly": "Send as real twonly!",
|
"protectAsARealTwonly": "Send as real twonly!",
|
||||||
"addDrawing": "Drawing",
|
"addDrawing": "Drawing",
|
||||||
"addEmoji": "Emoji",
|
"addEmoji": "Emoji",
|
||||||
|
"drawingColorPicker": "Pick color from image",
|
||||||
|
"createStickerFromImage": "Create sticker from image",
|
||||||
|
"stickerSelectionHint": "Move or resize the frame around the part you want as a sticker.",
|
||||||
|
"stickerCreating": "Creating sticker…",
|
||||||
"toggleFlashLight": "Toggle the flash light",
|
"toggleFlashLight": "Toggle the flash light",
|
||||||
"userFound": "{username} found",
|
"userFound": "{username} found",
|
||||||
"userFoundBody": "Do you want to connect with {username}?",
|
"userFoundBody": "Do you want to connect with {username}?",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:convert/convert.dart';
|
import 'package:convert/convert.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
|
@ -76,6 +77,22 @@ class StickerService {
|
||||||
return sticker;
|
return sticker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a sticker from encoded image bytes without making the caller
|
||||||
|
/// manage the temporary source file required by the native sticker model.
|
||||||
|
static Future<LocalSticker> createFromBytes(Uint8List imageBytes) async {
|
||||||
|
final source = File(
|
||||||
|
'${AppEnvironment.cacheDir}/sticker-source-'
|
||||||
|
'${DateTime.now().microsecondsSinceEpoch}.png',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await source.parent.create(recursive: true);
|
||||||
|
await source.writeAsBytes(imageBytes, flush: true);
|
||||||
|
return await createFromPath(source.path);
|
||||||
|
} finally {
|
||||||
|
if (source.existsSync()) source.deleteSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Finds the best full-resolution local source for a stored memory. A
|
/// Finds the best full-resolution local source for a stored memory. A
|
||||||
/// cloud-only memory is downloaded before its path is returned.
|
/// cloud-only memory is downloaded before its path is returned.
|
||||||
static Future<String?> resolveMediaPath(MediaFileService media) async {
|
static Future<String?> resolveMediaPath(MediaFileService media) async {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/services/stickers/sticker.service.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/keyvalue.dart';
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
||||||
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
|
||||||
|
|
@ -22,11 +24,13 @@ import 'package:twonly/src/visual/views/camera/share_image_editor_components/dis
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/display_time_picker.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/display_time_picker.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_canvas.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_canvas.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_image_tools.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/video_trimmer.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/video_trimmer.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
@ -104,6 +108,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
bool _previousMediaSettingsCaptured = false;
|
bool _previousMediaSettingsCaptured = false;
|
||||||
int? _displayLimitBeforeWidget;
|
int? _displayLimitBeforeWidget;
|
||||||
bool _requiresAuthBeforeWidget = false;
|
bool _requiresAuthBeforeWidget = false;
|
||||||
|
bool _selectingSticker = false;
|
||||||
|
bool _creatingSticker = false;
|
||||||
|
|
||||||
bool get _showWidgetExplainer =>
|
bool get _showWidgetExplainer =>
|
||||||
_widgetRecipientAvailable &&
|
_widgetRecipientAvailable &&
|
||||||
|
|
@ -438,6 +444,80 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<EditorColorSampler?> _loadEditorColorSampler() async {
|
||||||
|
final screenshot = await mediaWriter.captureCanvasImage(pixelRatio);
|
||||||
|
final image = screenshot?.image;
|
||||||
|
if (image == null) return null;
|
||||||
|
final sampler = await EditorImageColorSampler.fromImage(image);
|
||||||
|
return sampler?.sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startStickerSelection() {
|
||||||
|
if (_selectingSticker || _creatingSticker) return;
|
||||||
|
setState(() => _selectingSticker = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelStickerSelection() {
|
||||||
|
if (_creatingSticker) return;
|
||||||
|
setState(() => _selectingSticker = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createStickerFromSelection(
|
||||||
|
Rect selection,
|
||||||
|
Size editorSize,
|
||||||
|
) async {
|
||||||
|
if (_creatingSticker) return;
|
||||||
|
setState(() => _creatingSticker = true);
|
||||||
|
try {
|
||||||
|
final screenshot = await mediaWriter.captureCanvasImage(pixelRatio);
|
||||||
|
final screenshotBytes = await screenshot?.getBytes();
|
||||||
|
if (screenshotBytes == null) {
|
||||||
|
throw StateError('Could not capture the editor for a sticker.');
|
||||||
|
}
|
||||||
|
final croppedBytes = cropEditorImage(
|
||||||
|
encodedImage: screenshotBytes,
|
||||||
|
selection: selection,
|
||||||
|
editorSize: editorSize,
|
||||||
|
);
|
||||||
|
final sticker = await StickerService.createFromBytes(croppedBytes);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final stickerSize = selection.longestSide.clamp(80.0, 240.0);
|
||||||
|
final aspect = sticker.width / sticker.height;
|
||||||
|
final displayedWidth = aspect >= 1 ? stickerSize : stickerSize * aspect;
|
||||||
|
final displayedHeight = aspect >= 1 ? stickerSize / aspect : stickerSize;
|
||||||
|
layerStack.add(
|
||||||
|
StickerLayerData(
|
||||||
|
key: GlobalKey(),
|
||||||
|
webp: sticker.webp,
|
||||||
|
contentHash: sticker.contentHash,
|
||||||
|
width: sticker.width,
|
||||||
|
height: sticker.height,
|
||||||
|
size: stickerSize,
|
||||||
|
offset: centeredStickerLayerOffset(
|
||||||
|
editorSize: editorSize,
|
||||||
|
displayedStickerSize: Size(displayedWidth, displayedHeight),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
unawaitedRustCall(
|
||||||
|
twonlyDB.stickersDao.recordUse(sticker.contentHash),
|
||||||
|
'recordEditorStickerUse',
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_creatingSticker = false;
|
||||||
|
_selectingSticker = false;
|
||||||
|
});
|
||||||
|
} on Object catch (error) {
|
||||||
|
Log.warn('Could not create sticker from editor selection: $error');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _creatingSticker = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.lang.stickerCreateFailed)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onClosePressed() async {
|
Future<void> _onClosePressed() async {
|
||||||
if (!layerStack.hasUserAddedLayers) {
|
if (!layerStack.hasUserAddedLayers) {
|
||||||
Navigator.pop(context, false);
|
Navigator.pop(context, false);
|
||||||
|
|
@ -566,6 +646,11 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
|
if (_creatingSticker) return;
|
||||||
|
if (_selectingSticker) {
|
||||||
|
_cancelStickerSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await askToCloseThenClose();
|
await askToCloseThenClose();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|
@ -583,21 +668,24 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
tabDownPosition = details.globalPosition.dy;
|
tabDownPosition = details.globalPosition.dy;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: _onCanvasTap,
|
onTap: _selectingSticker ? null : _onCanvasTap,
|
||||||
child: MediaViewSizingHelper.cameraEditor(
|
child: MediaViewSizingHelper.cameraEditor(
|
||||||
bottomNavigation: EditorBottomBar(
|
bottomNavigation: IgnorePointer(
|
||||||
mediaService: mediaService,
|
ignoring: _selectingSticker,
|
||||||
sendToGroup: widget.sendToGroup,
|
child: EditorBottomBar(
|
||||||
isLoadingImage: loadingImage,
|
mediaService: mediaService,
|
||||||
isSending: sendingOrLoadingImage || _updatingWidgetMode,
|
sendToGroup: widget.sendToGroup,
|
||||||
storeImageAsOriginal: storeImageAsOriginal,
|
isLoadingImage: loadingImage,
|
||||||
onAddMoreRecipients: pushShareImageView,
|
isSending: sendingOrLoadingImage || _updatingWidgetMode,
|
||||||
onSend: () async {
|
storeImageAsOriginal: storeImageAsOriginal,
|
||||||
if (widget.sendToGroup == null) {
|
onAddMoreRecipients: pushShareImageView,
|
||||||
return pushShareImageView();
|
onSend: () async {
|
||||||
}
|
if (widget.sendToGroup == null) {
|
||||||
await sendImageToSinglePerson();
|
return pushShareImageView();
|
||||||
},
|
}
|
||||||
|
await sendImageToSinglePerson();
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: EditorCanvas(
|
child: EditorCanvas(
|
||||||
layerStack: layerStack,
|
layerStack: layerStack,
|
||||||
|
|
@ -620,22 +708,29 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
onChangeEnd: _persistTrim,
|
onChangeEnd: _persistTrim,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
stickerSelectionActive: _selectingSticker,
|
||||||
|
stickerSelectionBusy: _creatingSticker,
|
||||||
|
onCancelStickerSelection: _cancelStickerSelection,
|
||||||
|
onConfirmStickerSelection: _createStickerFromSelection,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (!_selectingSticker)
|
||||||
top: 10,
|
Positioned(
|
||||||
left: 5,
|
top: 10,
|
||||||
right: 0,
|
left: 5,
|
||||||
child: SafeArea(
|
right: 0,
|
||||||
child: EditorTopToolbar(
|
child: SafeArea(
|
||||||
layerStack: layerStack,
|
child: EditorTopToolbar(
|
||||||
onClose: _onClosePressed,
|
layerStack: layerStack,
|
||||||
onChanged: () => setState(() {}),
|
onClose: _onClosePressed,
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (!_selectingSticker &&
|
||||||
if (_showWidgetExplainer && widgetActionCenter != null)
|
_showWidgetExplainer &&
|
||||||
|
widgetActionCenter != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: math.max(
|
left: math.max(
|
||||||
12,
|
12,
|
||||||
|
|
@ -650,35 +745,40 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (!_selectingSticker)
|
||||||
right: 6,
|
Positioned(
|
||||||
top: 100,
|
right: 6,
|
||||||
child: Container(
|
top: 100,
|
||||||
alignment: Alignment.bottomCenter,
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
alignment: Alignment.bottomCenter,
|
||||||
child: SafeArea(
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: EditorSideToolbar(
|
child: SafeArea(
|
||||||
layerStack: layerStack,
|
child: EditorSideToolbar(
|
||||||
mediaService: mediaService,
|
layerStack: layerStack,
|
||||||
onChanged: () => setState(() {}),
|
mediaService: mediaService,
|
||||||
onEditDisplayTime: _editDisplayTime,
|
onChanged: () => setState(() {}),
|
||||||
onToggleAudio: _toggleAudio,
|
onEditDisplayTime: _editDisplayTime,
|
||||||
onToggleRequiresAuth: _toggleRequiresAuth,
|
onToggleAudio: _toggleAudio,
|
||||||
sendToWidget: _sendToWidget,
|
onToggleRequiresAuth: _toggleRequiresAuth,
|
||||||
showWidgetOption: _widgetRecipientAvailable,
|
sendToWidget: _sendToWidget,
|
||||||
highlightWidgetOption: _showWidgetExplainer,
|
showWidgetOption: _widgetRecipientAvailable,
|
||||||
isUpdatingWidgetMode: _updatingWidgetMode,
|
highlightWidgetOption: _showWidgetExplainer,
|
||||||
widgetActionKey: _widgetActionKey,
|
isUpdatingWidgetMode: _updatingWidgetMode,
|
||||||
onToggleSendToWidget: () =>
|
widgetActionKey: _widgetActionKey,
|
||||||
_setSendToWidget(!_sendToWidget),
|
onToggleSendToWidget: () =>
|
||||||
canTrim: _canTrim,
|
_setSendToWidget(!_sendToWidget),
|
||||||
trimmerVisible: _trimmerVisible,
|
colorSamplerLoader: media.type == MediaType.image
|
||||||
onToggleTrimmer: () =>
|
? _loadEditorColorSampler
|
||||||
setState(() => _trimmerVisible = !_trimmerVisible),
|
: null,
|
||||||
|
onCreateStickerFromImage: _startStickerSelection,
|
||||||
|
canTrim: _canTrim,
|
||||||
|
trimmerVisible: _trimmerVisible,
|
||||||
|
onToggleTrimmer: () =>
|
||||||
|
setState(() => _trimmerVisible = !_trimmerVisible),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers_viewer.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers_viewer.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/sticker_cutout_selector.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
/// The media itself with all editor layers stacked on top of it.
|
/// The media itself with all editor layers stacked on top of it.
|
||||||
|
|
@ -19,6 +20,10 @@ class EditorCanvas extends StatelessWidget {
|
||||||
required this.onLayersUpdated,
|
required this.onLayersUpdated,
|
||||||
this.videoController,
|
this.videoController,
|
||||||
this.bottomOverlay,
|
this.bottomOverlay,
|
||||||
|
this.stickerSelectionActive = false,
|
||||||
|
this.stickerSelectionBusy = false,
|
||||||
|
this.onCancelStickerSelection,
|
||||||
|
this.onConfirmStickerSelection,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -34,6 +39,11 @@ class EditorCanvas extends StatelessWidget {
|
||||||
/// Editor chrome laid over the bottom of the media, outside the [Screenshot]
|
/// Editor chrome laid over the bottom of the media, outside the [Screenshot]
|
||||||
/// so it never ends up burnt into what gets sent. Used by the video trimmer.
|
/// so it never ends up burnt into what gets sent. Used by the video trimmer.
|
||||||
final Widget? bottomOverlay;
|
final Widget? bottomOverlay;
|
||||||
|
final bool stickerSelectionActive;
|
||||||
|
final bool stickerSelectionBusy;
|
||||||
|
final VoidCallback? onCancelStickerSelection;
|
||||||
|
final Future<void> Function(Rect selection, Size editorSize)?
|
||||||
|
onConfirmStickerSelection;
|
||||||
|
|
||||||
bool get _hasVideo =>
|
bool get _hasVideo =>
|
||||||
videoController != null && videoController!.value.isInitialized;
|
videoController != null && videoController!.value.isInitialized;
|
||||||
|
|
@ -76,6 +86,14 @@ class EditorCanvas extends StatelessWidget {
|
||||||
_buildLayers(),
|
_buildLayers(),
|
||||||
if (bottomOverlay != null)
|
if (bottomOverlay != null)
|
||||||
Positioned(left: 0, right: 0, bottom: 0, child: bottomOverlay!),
|
Positioned(left: 0, right: 0, bottom: 0, child: bottomOverlay!),
|
||||||
|
if (stickerSelectionActive)
|
||||||
|
Positioned.fill(
|
||||||
|
child: StickerCutoutSelector(
|
||||||
|
busy: stickerSelectionBusy,
|
||||||
|
onCancel: onCancelStickerSelection!,
|
||||||
|
onConfirm: onConfirmStickerSelection!,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
/// Integer bounds of an editor selection in the rendered image.
|
||||||
|
class EditorImageCrop {
|
||||||
|
const EditorImageCrop({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int x;
|
||||||
|
final int y;
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-left layer offset that places the visible sticker in the editor center.
|
||||||
|
/// Sticker layers include interaction padding around the visible image.
|
||||||
|
Offset centeredStickerLayerOffset({
|
||||||
|
required Size editorSize,
|
||||||
|
required Size displayedStickerSize,
|
||||||
|
double interactionPadding = 36,
|
||||||
|
}) =>
|
||||||
|
editorSize.center(Offset.zero) -
|
||||||
|
Offset(
|
||||||
|
displayedStickerSize.width / 2 + interactionPadding,
|
||||||
|
displayedStickerSize.height / 2 + interactionPadding,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A single rendered editor frame whose colors can be sampled repeatedly
|
||||||
|
/// without taking a new screenshot for every pointer movement.
|
||||||
|
class EditorImageColorSampler {
|
||||||
|
const EditorImageColorSampler({
|
||||||
|
required this.bytes,
|
||||||
|
required this.imageWidth,
|
||||||
|
required this.imageHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ByteData bytes;
|
||||||
|
final int imageWidth;
|
||||||
|
final int imageHeight;
|
||||||
|
|
||||||
|
static Future<EditorImageColorSampler?> fromImage(ui.Image image) async {
|
||||||
|
final bytes = await image.toByteData();
|
||||||
|
if (bytes == null) return null;
|
||||||
|
return EditorImageColorSampler(
|
||||||
|
bytes: bytes,
|
||||||
|
imageWidth: image.width,
|
||||||
|
imageHeight: image.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? sample(Offset point, Size editorSize) {
|
||||||
|
final pixel = editorPointToImagePixel(
|
||||||
|
point: point,
|
||||||
|
editorSize: editorSize,
|
||||||
|
imageWidth: imageWidth,
|
||||||
|
imageHeight: imageHeight,
|
||||||
|
);
|
||||||
|
if (pixel == null) return null;
|
||||||
|
|
||||||
|
final offset = (pixel.y * imageWidth + pixel.x) * 4;
|
||||||
|
if (offset + 3 >= bytes.lengthInBytes) return null;
|
||||||
|
return Color.fromARGB(
|
||||||
|
bytes.getUint8(offset + 3),
|
||||||
|
bytes.getUint8(offset),
|
||||||
|
bytes.getUint8(offset + 1),
|
||||||
|
bytes.getUint8(offset + 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a logical editor position to one pixel in a rendered canvas image.
|
||||||
|
({int x, int y})? editorPointToImagePixel({
|
||||||
|
required Offset point,
|
||||||
|
required Size editorSize,
|
||||||
|
required int imageWidth,
|
||||||
|
required int imageHeight,
|
||||||
|
}) {
|
||||||
|
if (editorSize.isEmpty || imageWidth < 1 || imageHeight < 1) return null;
|
||||||
|
if (point.dx < 0 ||
|
||||||
|
point.dy < 0 ||
|
||||||
|
point.dx >= editorSize.width ||
|
||||||
|
point.dy >= editorSize.height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
x: (point.dx / editorSize.width * imageWidth).floor().clamp(
|
||||||
|
0,
|
||||||
|
imageWidth - 1,
|
||||||
|
),
|
||||||
|
y: (point.dy / editorSize.height * imageHeight).floor().clamp(
|
||||||
|
0,
|
||||||
|
imageHeight - 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a logical editor selection to a non-empty pixel crop.
|
||||||
|
EditorImageCrop editorRectToImageCrop({
|
||||||
|
required Rect selection,
|
||||||
|
required Size editorSize,
|
||||||
|
required int imageWidth,
|
||||||
|
required int imageHeight,
|
||||||
|
}) {
|
||||||
|
if (editorSize.isEmpty || imageWidth < 1 || imageHeight < 1) {
|
||||||
|
throw ArgumentError('The editor and image dimensions must be positive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final bounded = selection.intersect(Offset.zero & editorSize);
|
||||||
|
if (bounded.isEmpty) {
|
||||||
|
throw ArgumentError('The selection does not overlap the editor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final left = (bounded.left / editorSize.width * imageWidth).floor().clamp(
|
||||||
|
0,
|
||||||
|
imageWidth - 1,
|
||||||
|
);
|
||||||
|
final top = (bounded.top / editorSize.height * imageHeight).floor().clamp(
|
||||||
|
0,
|
||||||
|
imageHeight - 1,
|
||||||
|
);
|
||||||
|
final right = (bounded.right / editorSize.width * imageWidth).ceil().clamp(
|
||||||
|
left + 1,
|
||||||
|
imageWidth,
|
||||||
|
);
|
||||||
|
final bottom = (bounded.bottom / editorSize.height * imageHeight)
|
||||||
|
.ceil()
|
||||||
|
.clamp(top + 1, imageHeight);
|
||||||
|
|
||||||
|
return EditorImageCrop(
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
width: right - left,
|
||||||
|
height: bottom - top,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crops an encoded screenshot using logical coordinates from the editor.
|
||||||
|
Uint8List cropEditorImage({
|
||||||
|
required Uint8List encodedImage,
|
||||||
|
required Rect selection,
|
||||||
|
required Size editorSize,
|
||||||
|
}) {
|
||||||
|
final decoded = img.decodeImage(encodedImage);
|
||||||
|
if (decoded == null) throw const FormatException('Invalid editor image.');
|
||||||
|
|
||||||
|
final crop = editorRectToImageCrop(
|
||||||
|
selection: selection,
|
||||||
|
editorSize: editorSize,
|
||||||
|
imageWidth: decoded.width,
|
||||||
|
imageHeight: decoded.height,
|
||||||
|
);
|
||||||
|
final cropped = img.copyCrop(
|
||||||
|
decoded,
|
||||||
|
x: crop.x,
|
||||||
|
y: crop.y,
|
||||||
|
width: crop.width,
|
||||||
|
height: crop.height,
|
||||||
|
);
|
||||||
|
return img.encodePng(cropped);
|
||||||
|
}
|
||||||
|
|
@ -97,7 +97,9 @@ class EditorLayerStack {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void addDrawLayer() => add(DrawLayerData(key: GlobalKey()));
|
void addDrawLayer({EditorColorSamplerLoader? colorSamplerLoader}) => add(
|
||||||
|
DrawLayerData(key: GlobalKey(), colorSamplerLoader: colorSamplerLoader),
|
||||||
|
);
|
||||||
|
|
||||||
/// Toggles the crop/rotate mode of the background layer.
|
/// Toggles the crop/rotate mode of the background layer.
|
||||||
void toggleBackgroundEditing() {
|
void toggleBackgroundEditing() {
|
||||||
|
|
|
||||||
|
|
@ -39,22 +39,29 @@ class EditorMediaWriter {
|
||||||
final unmodified = layerStack.unmodifiedBackgroundImage;
|
final unmodified = layerStack.unmodifiedBackgroundImage;
|
||||||
if (unmodified != null) return unmodified;
|
if (unmodified != null) return unmodified;
|
||||||
|
|
||||||
|
return captureCanvasImage(pixelRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always captures the canvas as currently rendered. Unlike
|
||||||
|
/// [captureEditedImage], this deliberately bypasses the original-image fast
|
||||||
|
/// path because tools such as the eyedropper and sticker cutout work in
|
||||||
|
/// logical canvas coordinates.
|
||||||
|
Future<ScreenshotImageHelper?> captureCanvasImage(double pixelRatio) async {
|
||||||
layerStack.setCustomButtonsVisible(visible: false);
|
layerStack.setCustomButtonsVisible(visible: false);
|
||||||
requestRebuild();
|
requestRebuild();
|
||||||
|
|
||||||
// Make a short delay, so the rebuild does have its effect...
|
try {
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
// Give the frame without editor controls time to reach the boundary.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||||
final image = await screenshotController.capture(pixelRatio: pixelRatio);
|
final image = await screenshotController.capture(pixelRatio: pixelRatio);
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
Log.warn('screenshotController did not return image bytes');
|
Log.warn('screenshotController did not return image bytes');
|
||||||
return null;
|
}
|
||||||
|
return image;
|
||||||
|
} finally {
|
||||||
|
layerStack.setCustomButtonsVisible(visible: true);
|
||||||
|
requestRebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
layerStack.setCustomButtonsVisible(visible: true);
|
|
||||||
requestRebuild();
|
|
||||||
|
|
||||||
return image;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the edited media to disk, replacing any previously written
|
/// Writes the edited media to disk, replacing any previously written
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ class EditorSideToolbar extends StatelessWidget {
|
||||||
required this.isUpdatingWidgetMode,
|
required this.isUpdatingWidgetMode,
|
||||||
required this.widgetActionKey,
|
required this.widgetActionKey,
|
||||||
required this.onToggleSendToWidget,
|
required this.onToggleSendToWidget,
|
||||||
|
required this.colorSamplerLoader,
|
||||||
|
required this.onCreateStickerFromImage,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -52,6 +54,8 @@ class EditorSideToolbar extends StatelessWidget {
|
||||||
final bool isUpdatingWidgetMode;
|
final bool isUpdatingWidgetMode;
|
||||||
final GlobalKey widgetActionKey;
|
final GlobalKey widgetActionKey;
|
||||||
final VoidCallback onToggleSendToWidget;
|
final VoidCallback onToggleSendToWidget;
|
||||||
|
final EditorColorSamplerLoader? colorSamplerLoader;
|
||||||
|
final VoidCallback onCreateStickerFromImage;
|
||||||
|
|
||||||
MediaFile get media => mediaService.mediaFile;
|
MediaFile get media => mediaService.mediaFile;
|
||||||
|
|
||||||
|
|
@ -95,7 +99,7 @@ class EditorSideToolbar extends StatelessWidget {
|
||||||
Icons.draw_rounded,
|
Icons.draw_rounded,
|
||||||
tooltipText: context.lang.addDrawing,
|
tooltipText: context.lang.addDrawing,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
layerStack.addDrawLayer();
|
layerStack.addDrawLayer(colorSamplerLoader: colorSamplerLoader);
|
||||||
onChanged();
|
onChanged();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -167,6 +171,14 @@ class EditorSideToolbar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (media.type == MediaType.image) ...[
|
if (media.type == MediaType.image) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ActionButton(
|
||||||
|
Icons.select_all_rounded,
|
||||||
|
key: const Key('createStickerFromImage'),
|
||||||
|
tooltipText: context.lang.createStickerFromImage,
|
||||||
|
color: context.appColor(AppColor.mediaForeground),
|
||||||
|
onPressed: onCreateStickerFromImage,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ActionButton(
|
ActionButton(
|
||||||
Icons.crop_rotate_outlined,
|
Icons.crop_rotate_outlined,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import 'package:hand_signature/signature.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';
|
||||||
|
|
||||||
|
typedef EditorColorSampler = Color? Function(Offset position, Size editorSize);
|
||||||
|
typedef EditorColorSamplerLoader = Future<EditorColorSampler?> Function();
|
||||||
|
|
||||||
/// Layer class with some common properties
|
/// Layer class with some common properties
|
||||||
class Layer {
|
class Layer {
|
||||||
Layer({
|
Layer({
|
||||||
|
|
@ -111,6 +114,7 @@ class TextLayerData extends Layer {
|
||||||
class DrawLayerData extends Layer {
|
class DrawLayerData extends Layer {
|
||||||
DrawLayerData({
|
DrawLayerData({
|
||||||
required super.key,
|
required super.key,
|
||||||
|
this.colorSamplerLoader,
|
||||||
super.offset,
|
super.offset,
|
||||||
super.opacity,
|
super.opacity,
|
||||||
super.rotation,
|
super.rotation,
|
||||||
|
|
@ -118,6 +122,7 @@ class DrawLayerData extends Layer {
|
||||||
super.hasCustomActionButtons = true,
|
super.hasCustomActionButtons = true,
|
||||||
super.isEditing = true,
|
super.isEditing = true,
|
||||||
});
|
});
|
||||||
|
final EditorColorSamplerLoader? colorSamplerLoader;
|
||||||
final control = HandSignatureControl(
|
final control = HandSignatureControl(
|
||||||
// ignore: prefer_const_constructors
|
// ignore: prefer_const_constructors
|
||||||
setup: () => SignaturePathSetup(
|
setup: () => SignaturePathSetup(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
List<CubicPath> undoList = [];
|
List<CubicPath> undoList = [];
|
||||||
bool skipNextEvent = false;
|
bool skipNextEvent = false;
|
||||||
bool showMagnifyingGlass = false;
|
bool showMagnifyingGlass = false;
|
||||||
|
bool _eyedropperActive = false;
|
||||||
|
bool _loadingColorSampler = false;
|
||||||
|
Offset? _eyedropperPosition;
|
||||||
|
Color? _previewColor;
|
||||||
|
EditorColorSampler? _colorSampler;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -75,6 +80,55 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleEyedropper() async {
|
||||||
|
if (_eyedropperActive) {
|
||||||
|
setState(() {
|
||||||
|
_eyedropperActive = false;
|
||||||
|
_colorSampler = null;
|
||||||
|
_previewColor = null;
|
||||||
|
showMagnifyingGlass = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loader = widget.layerData.colorSamplerLoader;
|
||||||
|
if (loader == null || _loadingColorSampler) return;
|
||||||
|
setState(() => _loadingColorSampler = true);
|
||||||
|
final sampler = await loader();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loadingColorSampler = false;
|
||||||
|
_colorSampler = sampler;
|
||||||
|
_eyedropperActive = sampler != null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _previewSample(Offset position, Size editorSize) {
|
||||||
|
final color = _colorSampler?.call(position, editorSize);
|
||||||
|
setState(() {
|
||||||
|
_eyedropperPosition = position;
|
||||||
|
_previewColor = color ?? _previewColor ?? currentColor;
|
||||||
|
showMagnifyingGlass = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _commitSample(Offset position, Size editorSize) {
|
||||||
|
final color = _colorSampler?.call(position, editorSize) ?? _previewColor;
|
||||||
|
setState(() {
|
||||||
|
if (color != null) currentColor = color;
|
||||||
|
_eyedropperActive = false;
|
||||||
|
_colorSampler = null;
|
||||||
|
_previewColor = null;
|
||||||
|
showMagnifyingGlass = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelSample() {
|
||||||
|
setState(() {
|
||||||
|
_previewColor = null;
|
||||||
|
showMagnifyingGlass = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
|
|
@ -88,6 +142,24 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
width: 7,
|
width: 7,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.layerData.isEditing &&
|
||||||
|
widget.layerData.showCustomButtons &&
|
||||||
|
_eyedropperActive)
|
||||||
|
Positioned.fill(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) => Listener(
|
||||||
|
key: const Key('drawingEyedropperTarget'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: (event) =>
|
||||||
|
_previewSample(event.localPosition, constraints.biggest),
|
||||||
|
onPointerMove: (event) =>
|
||||||
|
_previewSample(event.localPosition, constraints.biggest),
|
||||||
|
onPointerUp: (event) =>
|
||||||
|
_commitSample(event.localPosition, constraints.biggest),
|
||||||
|
onPointerCancel: (_) => _cancelSample(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (widget.layerData.isEditing && widget.layerData.showCustomButtons)
|
if (widget.layerData.isEditing && widget.layerData.showCustomButtons)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 5,
|
top: 5,
|
||||||
|
|
@ -137,6 +209,7 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
),
|
),
|
||||||
if (widget.layerData.isEditing && widget.layerData.showCustomButtons)
|
if (widget.layerData.isEditing && widget.layerData.showCustomButtons)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
key: const Key('drawingColorSlider'),
|
||||||
right: 20,
|
right: 20,
|
||||||
top: 50,
|
top: 50,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
|
@ -180,6 +253,8 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
onChanged: _onSliderChanged,
|
onChanged: _onSliderChanged,
|
||||||
onChangeStart: (value) => {
|
onChangeStart: (value) => {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_eyedropperPosition = null;
|
||||||
|
_previewColor = null;
|
||||||
showMagnifyingGlass = true;
|
showMagnifyingGlass = true;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
@ -195,11 +270,39 @@ class _DrawLayerState extends State<DrawLayer> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.layerData.isEditing &&
|
||||||
|
widget.layerData.showCustomButtons &&
|
||||||
|
widget.layerData.colorSamplerLoader != null)
|
||||||
|
Positioned(
|
||||||
|
right: 14,
|
||||||
|
top: 290,
|
||||||
|
child: ActionButton(
|
||||||
|
Icons.colorize_rounded,
|
||||||
|
key: const Key('drawingEyedropper'),
|
||||||
|
tooltipText: context.lang.drawingColorPicker,
|
||||||
|
color: _eyedropperActive
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: context.appColor(AppColor.mediaForeground),
|
||||||
|
disable: _loadingColorSampler,
|
||||||
|
onPressed: _toggleEyedropper,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (showMagnifyingGlass)
|
if (showMagnifyingGlass)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 80,
|
right: _eyedropperPosition == null ? 80 : null,
|
||||||
top: 50 + (185 * _sliderValue),
|
left: _eyedropperPosition == null
|
||||||
child: MagnifyingGlass(color: currentColor),
|
? null
|
||||||
|
: (_eyedropperPosition!.dx - 25).clamp(
|
||||||
|
4,
|
||||||
|
MediaQuery.sizeOf(context).width - 54,
|
||||||
|
),
|
||||||
|
top: _eyedropperPosition == null
|
||||||
|
? 50 + (185 * _sliderValue)
|
||||||
|
: (_eyedropperPosition!.dy - 72).clamp(
|
||||||
|
4,
|
||||||
|
MediaQuery.sizeOf(context).height - 54,
|
||||||
|
),
|
||||||
|
child: MagnifyingGlass(color: _previewColor ?? currentColor),
|
||||||
),
|
),
|
||||||
// if (!widget.layerData.isEditing)
|
// if (!widget.layerData.isEditing)
|
||||||
// Positioned.fill(
|
// Positioned.fill(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
|
||||||
|
|
||||||
|
enum _SelectionDragMode {
|
||||||
|
newSelection,
|
||||||
|
move,
|
||||||
|
topLeft,
|
||||||
|
topRight,
|
||||||
|
bottomLeft,
|
||||||
|
bottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An editor overlay for choosing the part of the canvas that becomes a
|
||||||
|
/// sticker. Dragging outside the current frame starts a new selection.
|
||||||
|
class StickerCutoutSelector extends StatefulWidget {
|
||||||
|
const StickerCutoutSelector({
|
||||||
|
required this.busy,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.onConfirm,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool busy;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final Future<void> Function(Rect selection, Size editorSize) onConfirm;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StickerCutoutSelector> createState() => _StickerCutoutSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickerCutoutSelectorState extends State<StickerCutoutSelector> {
|
||||||
|
static const _minimumSelection = 56.0;
|
||||||
|
static const _handleHitRadius = 34.0;
|
||||||
|
|
||||||
|
Rect? _selection;
|
||||||
|
Rect _selectionAtDragStart = Rect.zero;
|
||||||
|
Offset _dragStart = Offset.zero;
|
||||||
|
_SelectionDragMode _dragMode = _SelectionDragMode.move;
|
||||||
|
|
||||||
|
Rect _initialSelection(Size size) {
|
||||||
|
final width = math.max(
|
||||||
|
_minimumSelection,
|
||||||
|
math.min(size.width * 0.62, size.width - 32),
|
||||||
|
);
|
||||||
|
final height = math.max(
|
||||||
|
_minimumSelection,
|
||||||
|
math.min(size.height * 0.38, size.height - 120),
|
||||||
|
);
|
||||||
|
return Rect.fromCenter(
|
||||||
|
center: size.center(Offset.zero),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_SelectionDragMode _modeFor(Offset point, Rect selection) {
|
||||||
|
final corners = <(_SelectionDragMode, Offset)>[
|
||||||
|
(_SelectionDragMode.topLeft, selection.topLeft),
|
||||||
|
(_SelectionDragMode.topRight, selection.topRight),
|
||||||
|
(_SelectionDragMode.bottomLeft, selection.bottomLeft),
|
||||||
|
(_SelectionDragMode.bottomRight, selection.bottomRight),
|
||||||
|
];
|
||||||
|
for (final corner in corners) {
|
||||||
|
if ((point - corner.$2).distance <= _handleHitRadius) return corner.$1;
|
||||||
|
}
|
||||||
|
return selection.contains(point)
|
||||||
|
? _SelectionDragMode.move
|
||||||
|
: _SelectionDragMode.newSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _boundedPoint(Offset point, Size size) => Offset(
|
||||||
|
point.dx.clamp(0, size.width),
|
||||||
|
point.dy.clamp(0, size.height),
|
||||||
|
);
|
||||||
|
|
||||||
|
Rect _moveSelection(Offset delta, Size size) {
|
||||||
|
var moved = _selectionAtDragStart.shift(delta);
|
||||||
|
if (moved.left < 0) moved = moved.shift(Offset(-moved.left, 0));
|
||||||
|
if (moved.top < 0) moved = moved.shift(Offset(0, -moved.top));
|
||||||
|
if (moved.right > size.width) {
|
||||||
|
moved = moved.shift(Offset(size.width - moved.right, 0));
|
||||||
|
}
|
||||||
|
if (moved.bottom > size.height) {
|
||||||
|
moved = moved.shift(Offset(0, size.height - moved.bottom));
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect _resizeSelection(Offset point, Size size) {
|
||||||
|
final bounded = _boundedPoint(point, size);
|
||||||
|
final start = _selectionAtDragStart;
|
||||||
|
return switch (_dragMode) {
|
||||||
|
_SelectionDragMode.topLeft => Rect.fromLTRB(
|
||||||
|
math.min(bounded.dx, start.right - _minimumSelection),
|
||||||
|
math.min(bounded.dy, start.bottom - _minimumSelection),
|
||||||
|
start.right,
|
||||||
|
start.bottom,
|
||||||
|
),
|
||||||
|
_SelectionDragMode.topRight => Rect.fromLTRB(
|
||||||
|
start.left,
|
||||||
|
math.min(bounded.dy, start.bottom - _minimumSelection),
|
||||||
|
math.max(bounded.dx, start.left + _minimumSelection),
|
||||||
|
start.bottom,
|
||||||
|
),
|
||||||
|
_SelectionDragMode.bottomLeft => Rect.fromLTRB(
|
||||||
|
math.min(bounded.dx, start.right - _minimumSelection),
|
||||||
|
start.top,
|
||||||
|
start.right,
|
||||||
|
math.max(bounded.dy, start.top + _minimumSelection),
|
||||||
|
),
|
||||||
|
_SelectionDragMode.bottomRight => Rect.fromLTRB(
|
||||||
|
start.left,
|
||||||
|
start.top,
|
||||||
|
math.max(bounded.dx, start.left + _minimumSelection),
|
||||||
|
math.max(bounded.dy, start.top + _minimumSelection),
|
||||||
|
),
|
||||||
|
_ => start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect _newSelection(Offset point, Size size) {
|
||||||
|
final bounded = _boundedPoint(point, size);
|
||||||
|
var left = math.min(_dragStart.dx, bounded.dx);
|
||||||
|
var top = math.min(_dragStart.dy, bounded.dy);
|
||||||
|
var right = math.max(_dragStart.dx, bounded.dx);
|
||||||
|
var bottom = math.max(_dragStart.dy, bounded.dy);
|
||||||
|
if (right - left < _minimumSelection) {
|
||||||
|
if (_dragStart.dx + _minimumSelection <= size.width) {
|
||||||
|
right = left + _minimumSelection;
|
||||||
|
} else {
|
||||||
|
left = math.max(0, right - _minimumSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bottom - top < _minimumSelection) {
|
||||||
|
if (_dragStart.dy + _minimumSelection <= size.height) {
|
||||||
|
bottom = top + _minimumSelection;
|
||||||
|
} else {
|
||||||
|
top = math.max(0, bottom - _minimumSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanStart(DragStartDetails details, Size size) {
|
||||||
|
if (widget.busy) return;
|
||||||
|
final selection = _selection ?? _initialSelection(size);
|
||||||
|
_dragStart = _boundedPoint(details.localPosition, size);
|
||||||
|
_selectionAtDragStart = selection;
|
||||||
|
_dragMode = _modeFor(_dragStart, selection);
|
||||||
|
if (_dragMode == _SelectionDragMode.newSelection) {
|
||||||
|
_selectionAtDragStart = Rect.fromLTWH(
|
||||||
|
_dragStart.dx,
|
||||||
|
_dragStart.dy,
|
||||||
|
_minimumSelection,
|
||||||
|
_minimumSelection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanUpdate(DragUpdateDetails details, Size size) {
|
||||||
|
if (widget.busy) return;
|
||||||
|
final point = _boundedPoint(details.localPosition, size);
|
||||||
|
setState(() {
|
||||||
|
_selection = switch (_dragMode) {
|
||||||
|
_SelectionDragMode.move => _moveSelection(point - _dragStart, size),
|
||||||
|
_SelectionDragMode.newSelection => _newSelection(point, size),
|
||||||
|
_ => _resizeSelection(point, size),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final size = constraints.biggest;
|
||||||
|
final selection = _selection ??= _initialSelection(size);
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
key: const Key('stickerCutoutSelection'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPanStart: (details) => _onPanStart(details, size),
|
||||||
|
onPanUpdate: (details) => _onPanUpdate(details, size),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _StickerSelectionPainter(selection),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 5,
|
||||||
|
left: 5,
|
||||||
|
right: 50,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ActionButton(
|
||||||
|
FontAwesomeIcons.xmark,
|
||||||
|
key: const Key('stickerCutoutCancel'),
|
||||||
|
tooltipText: context.lang.cancel,
|
||||||
|
disable: widget.busy,
|
||||||
|
onPressed: widget.onCancel,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ActionButton(
|
||||||
|
FontAwesomeIcons.check,
|
||||||
|
key: const Key('stickerCutoutConfirm'),
|
||||||
|
tooltipText: context.lang.createStickerFromImage,
|
||||||
|
disable: widget.busy,
|
||||||
|
onPressed: () => widget.onConfirm(selection, size),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 62,
|
||||||
|
left: 28,
|
||||||
|
right: 28,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.lang.stickerSelectionHint,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.busy)
|
||||||
|
Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(color: Colors.white),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
context.lang.stickerCreating,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickerSelectionPainter extends CustomPainter {
|
||||||
|
const _StickerSelectionPainter(this.selection);
|
||||||
|
|
||||||
|
final Rect selection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final outside = Path()
|
||||||
|
..fillType = PathFillType.evenOdd
|
||||||
|
..addRect(Offset.zero & size)
|
||||||
|
..addRRect(RRect.fromRectAndRadius(selection, const Radius.circular(12)));
|
||||||
|
final selectionShape = RRect.fromRectAndRadius(
|
||||||
|
selection,
|
||||||
|
const Radius.circular(12),
|
||||||
|
);
|
||||||
|
canvas
|
||||||
|
..drawPath(outside, Paint()..color = Colors.black54)
|
||||||
|
..drawRRect(
|
||||||
|
selectionShape,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.5,
|
||||||
|
);
|
||||||
|
final handlePaint = Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
for (final corner in [
|
||||||
|
selection.topLeft,
|
||||||
|
selection.topRight,
|
||||||
|
selection.bottomLeft,
|
||||||
|
selection.bottomRight,
|
||||||
|
]) {
|
||||||
|
canvas.drawCircle(corner, 7, handlePaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _StickerSelectionPainter oldDelegate) =>
|
||||||
|
selection != oldDelegate.selection;
|
||||||
|
}
|
||||||
|
|
@ -271,6 +271,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
.toList();
|
.toList();
|
||||||
if (msgs.isNotEmpty &&
|
if (msgs.isNotEmpty &&
|
||||||
msgs.first.type == MessageType.media.name &&
|
msgs.first.type == MessageType.media.name &&
|
||||||
|
msgs.first.mediaId != null &&
|
||||||
!msgs.first.isDeletedFromSender &&
|
!msgs.first.isDeletedFromSender &&
|
||||||
msgs.first.senderId != null &&
|
msgs.first.senderId != null &&
|
||||||
msgs.first.openedAt == null) {
|
msgs.first.openedAt == null) {
|
||||||
|
|
@ -320,19 +321,23 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_hasNonOpenedMediaFile) {
|
if (_hasNonOpenedMediaFile) {
|
||||||
final msgs = _previewMessages
|
final message = _previewMessages
|
||||||
.where((x) => x.type == MessageType.media.name && !x.isWidgetMedia)
|
.where(
|
||||||
.toList();
|
(x) =>
|
||||||
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
|
x.type == MessageType.media.name &&
|
||||||
msgs.first.mediaId!,
|
!x.isWidgetMedia &&
|
||||||
);
|
x.mediaId != null,
|
||||||
if (mediaFile?.type != MediaType.audio) {
|
)
|
||||||
if (mediaFile?.downloadState == null) return;
|
.firstOrNull;
|
||||||
if (mediaFile!.downloadState! == DownloadState.pending) {
|
final mediaFile = message == null
|
||||||
|
? null
|
||||||
|
: await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
|
||||||
|
if (mediaFile != null && mediaFile.type != MediaType.audio) {
|
||||||
|
if (mediaFile.downloadState == DownloadState.pending) {
|
||||||
await startDownloadMedia(mediaFile, true);
|
await startDownloadMedia(mediaFile, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mediaFile.downloadState! == DownloadState.ready) {
|
if (mediaFile.downloadState == DownloadState.ready) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await context.push(
|
await context.push(
|
||||||
Routes.chatsMediaViewer,
|
Routes.chatsMediaViewer,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNotNull, isNull;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
|
|
@ -146,4 +147,78 @@ void main() {
|
||||||
await groupSub.cancel();
|
await groupSub.cancel();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'chat summaries ignore unopened media after its file is removed',
|
||||||
|
() async {
|
||||||
|
const groupId = 'group_with_removed_media';
|
||||||
|
final now = DateTime(2026, 10, 6, 12);
|
||||||
|
|
||||||
|
await twonlyDB.groupsDao.createNewGroup(
|
||||||
|
GroupsCompanion.insert(groupId: groupId, groupName: 'Removed media'),
|
||||||
|
);
|
||||||
|
await twonlyDB.contactsDao.insertOnConflictUpdate(
|
||||||
|
ContactsCompanion.insert(
|
||||||
|
userId: const Value(2),
|
||||||
|
username: 'sender',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await twonlyDB.messagesDao.insertMessage(
|
||||||
|
MessagesCompanion.insert(
|
||||||
|
messageId: 'older_text',
|
||||||
|
groupId: groupId,
|
||||||
|
senderId: const Value(2),
|
||||||
|
type: MessageType.text.name,
|
||||||
|
content: const Value('Still visible'),
|
||||||
|
createdAt: Value(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||||
|
MediaFilesCompanion.insert(
|
||||||
|
mediaId: 'removed_media',
|
||||||
|
type: MediaType.image,
|
||||||
|
downloadState: const Value(DownloadState.ready),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await twonlyDB.messagesDao.insertMessage(
|
||||||
|
MessagesCompanion.insert(
|
||||||
|
messageId: 'broken_media_message',
|
||||||
|
groupId: groupId,
|
||||||
|
senderId: const Value(2),
|
||||||
|
type: MessageType.media.name,
|
||||||
|
mediaId: const Value('removed_media'),
|
||||||
|
createdAt: Value(now.add(const Duration(minutes: 1))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile('removed_media');
|
||||||
|
|
||||||
|
final brokenMessage = await twonlyDB.messagesDao
|
||||||
|
.getMessageById('broken_media_message')
|
||||||
|
.getSingleOrNull();
|
||||||
|
expect(brokenMessage, isNotNull);
|
||||||
|
expect(brokenMessage!.mediaId, isNull);
|
||||||
|
expect(await twonlyDB.messagesDao.watchMessageNotOpened(groupId).first, [
|
||||||
|
isA<Message>().having(
|
||||||
|
(message) => message.messageId,
|
||||||
|
'messageId',
|
||||||
|
'older_text',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final allUnopened = await twonlyDB.messagesDao
|
||||||
|
.watchAllMessagesNotOpened()
|
||||||
|
.first;
|
||||||
|
expect(
|
||||||
|
allUnopened.map((message) => message.messageId),
|
||||||
|
isNot(contains('broken_media_message')),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await twonlyDB.messagesDao.watchLatestMessagesByGroup().first)
|
||||||
|
.single
|
||||||
|
.messageId,
|
||||||
|
'older_text',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
test/visual/editor_image_tools_test.dart
Normal file
99
test/visual/editor_image_tools_test.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_image_tools.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('maps editor points to rendered image pixels', () {
|
||||||
|
expect(
|
||||||
|
editorPointToImagePixel(
|
||||||
|
point: const Offset(25, 100),
|
||||||
|
editorSize: const Size(100, 200),
|
||||||
|
imageWidth: 400,
|
||||||
|
imageHeight: 800,
|
||||||
|
),
|
||||||
|
(x: 100, y: 400),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
editorPointToImagePixel(
|
||||||
|
point: const Offset(100, 10),
|
||||||
|
editorSize: const Size(100, 200),
|
||||||
|
imageWidth: 400,
|
||||||
|
imageHeight: 800,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps fractional selections outward to complete pixels', () {
|
||||||
|
final crop = editorRectToImageCrop(
|
||||||
|
selection: const Rect.fromLTRB(10.2, 20.2, 49.8, 79.8),
|
||||||
|
editorSize: const Size(100, 100),
|
||||||
|
imageWidth: 200,
|
||||||
|
imageHeight: 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((crop.x, crop.y), (20, 60));
|
||||||
|
expect((crop.width, crop.height), (80, 180));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses one rendered frame for multiple color samples', () {
|
||||||
|
final bytes = ByteData(8)
|
||||||
|
..setUint8(0, 10)
|
||||||
|
..setUint8(1, 20)
|
||||||
|
..setUint8(2, 30)
|
||||||
|
..setUint8(3, 255)
|
||||||
|
..setUint8(4, 40)
|
||||||
|
..setUint8(5, 50)
|
||||||
|
..setUint8(6, 60)
|
||||||
|
..setUint8(7, 255);
|
||||||
|
final sampler = EditorImageColorSampler(
|
||||||
|
bytes: bytes,
|
||||||
|
imageWidth: 2,
|
||||||
|
imageHeight: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sampler.sample(const Offset(10, 5), const Size(100, 10)),
|
||||||
|
const Color.fromARGB(255, 10, 20, 30),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
sampler.sample(const Offset(90, 5), const Size(100, 10)),
|
||||||
|
const Color.fromARGB(255, 40, 50, 60),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('centers a sticker independently of the selected source area', () {
|
||||||
|
final offset = centeredStickerLayerOffset(
|
||||||
|
editorSize: const Size(300, 500),
|
||||||
|
displayedStickerSize: const Size(120, 80),
|
||||||
|
);
|
||||||
|
final visibleCenter = offset + const Offset(36 + 60, 36 + 40);
|
||||||
|
|
||||||
|
expect(visibleCenter, const Offset(150, 250));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crops the selected editor region', () {
|
||||||
|
final source = img.Image(width: 4, height: 4);
|
||||||
|
for (var y = 0; y < source.height; y++) {
|
||||||
|
for (var x = 0; x < source.width; x++) {
|
||||||
|
source.setPixelRgba(x, y, x * 50, y * 50, 0, 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytes = cropEditorImage(
|
||||||
|
encodedImage: Uint8List.fromList(img.encodePng(source)),
|
||||||
|
selection: const Rect.fromLTWH(1, 1, 2, 2),
|
||||||
|
editorSize: const Size(4, 4),
|
||||||
|
);
|
||||||
|
final cropped = img.decodePng(bytes)!;
|
||||||
|
|
||||||
|
expect((cropped.width, cropped.height), (2, 2));
|
||||||
|
expect(cropped.getPixel(0, 0).r, 50);
|
||||||
|
expect(cropped.getPixel(0, 0).g, 50);
|
||||||
|
expect(cropped.getPixel(1, 1).r, 100);
|
||||||
|
expect(cropped.getPixel(1, 1).g, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
138
test/visual/share_image_editor_tools_test.dart
Normal file
138
test/visual/share_image_editor_tools_test.dart
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:twonly/src/localization/generated/app_localizations.dart';
|
||||||
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/draw.layer.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/draw/custom_hand_signature.dart';
|
||||||
|
import 'package:twonly/src/visual/views/camera/share_image_editor_components/sticker_cutout_selector.dart';
|
||||||
|
|
||||||
|
Widget _app(Widget child) => MaterialApp(
|
||||||
|
theme: lightTheme,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: Center(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('sticker selector moves its frame and confirms the selection', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
Rect? confirmedSelection;
|
||||||
|
Size? confirmedSize;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_app(
|
||||||
|
SizedBox(
|
||||||
|
width: 300,
|
||||||
|
height: 500,
|
||||||
|
child: StickerCutoutSelector(
|
||||||
|
busy: false,
|
||||||
|
onCancel: () {},
|
||||||
|
onConfirm: (selection, editorSize) async {
|
||||||
|
confirmedSelection = selection;
|
||||||
|
confirmedSize = editorSize;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final target = find.byKey(const Key('stickerCutoutSelection'));
|
||||||
|
final center = tester.getCenter(target);
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await gesture.moveBy(const Offset(20, 0));
|
||||||
|
await gesture.moveBy(const Offset(20, 30));
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const Key('stickerCutoutConfirm')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(confirmedSize, const Size(300, 500));
|
||||||
|
expect(confirmedSelection, isNotNull);
|
||||||
|
expect(confirmedSelection!.center, const Offset(190, 280));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('drawing eyedropper applies the sampled canvas color', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var loaderCalls = 0;
|
||||||
|
Offset? sampledPosition;
|
||||||
|
Size? sampledSize;
|
||||||
|
final layer = DrawLayerData(
|
||||||
|
key: GlobalKey(),
|
||||||
|
colorSamplerLoader: () async {
|
||||||
|
loaderCalls++;
|
||||||
|
Color? sampler(Offset position, Size editorSize) {
|
||||||
|
sampledPosition = position;
|
||||||
|
sampledSize = editorSize;
|
||||||
|
return position.dx < 150 ? Colors.blue : Colors.teal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampler;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_app(
|
||||||
|
SizedBox(
|
||||||
|
width: 300,
|
||||||
|
height: 500,
|
||||||
|
child: DrawLayer(layerData: layer, onUpdate: () {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sliderRect = tester.getRect(
|
||||||
|
find.byKey(const Key('drawingColorSlider')),
|
||||||
|
);
|
||||||
|
final eyedropperRect = tester.getRect(
|
||||||
|
find.byKey(const Key('drawingEyedropper')),
|
||||||
|
);
|
||||||
|
expect(eyedropperRect.top, greaterThanOrEqualTo(sliderRect.bottom));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('drawingEyedropper')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(loaderCalls, 1);
|
||||||
|
final target = find.byKey(const Key('drawingEyedropperTarget'));
|
||||||
|
final targetTopLeft = tester.getTopLeft(target);
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
targetTopLeft + const Offset(80, 180),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.widget<MagnifyingGlass>(find.byType(MagnifyingGlass)).color,
|
||||||
|
Colors.blue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CustomHandSignature>(find.byType(CustomHandSignature))
|
||||||
|
.currentColor,
|
||||||
|
Colors.red,
|
||||||
|
);
|
||||||
|
|
||||||
|
await gesture.moveTo(targetTopLeft + const Offset(220, 300));
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
tester.widget<MagnifyingGlass>(find.byType(MagnifyingGlass)).color,
|
||||||
|
Colors.teal,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CustomHandSignature>(find.byType(CustomHandSignature))
|
||||||
|
.currentColor,
|
||||||
|
Colors.red,
|
||||||
|
);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
expect(sampledPosition, const Offset(220, 300));
|
||||||
|
expect(sampledSize, const Size(300, 500));
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CustomHandSignature>(find.byType(CustomHandSignature))
|
||||||
|
.currentColor,
|
||||||
|
Colors.teal,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('drawingEyedropperTarget')), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue