From 3efaea3175b085b9c755cb19e81f366ccf1733b2 Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 6 Oct 2026 22:52:57 +0200 Subject: [PATCH] improve image editor --- lib/src/database/daos/messages.dao.dart | 17 + .../generated/app_localizations.dart | 24 ++ .../generated/app_localizations_ar.dart | 13 + .../generated/app_localizations_de.dart | 13 + .../generated/app_localizations_en.dart | 13 + lib/src/localization/translations/ar.arb | 4 + lib/src/localization/translations/de.arb | 4 + lib/src/localization/translations/en.arb | 4 + .../services/stickers/sticker.service.dart | 17 + .../views/camera/share_image_editor.view.dart | 202 +++++++++--- .../editor_canvas.dart | 18 + .../editor_image_tools.dart | 168 ++++++++++ .../editor_layer_stack.dart | 4 +- .../editor_media_writer.dart | 31 +- .../editor_side_toolbar.dart | 14 +- .../layer_data.dart | 5 + .../layers/draw.layer.dart | 109 +++++- .../sticker_cutout_selector.dart | 310 ++++++++++++++++++ .../group_list_item.comp.dart | 25 +- test/services/messages_stream_test.dart | 75 +++++ test/visual/editor_image_tools_test.dart | 99 ++++++ .../visual/share_image_editor_tools_test.dart | 138 ++++++++ 22 files changed, 1229 insertions(+), 78 deletions(-) create mode 100644 lib/src/visual/views/camera/share_image_editor_components/editor_image_tools.dart create mode 100644 lib/src/visual/views/camera/share_image_editor_components/sticker_cutout_selector.dart create mode 100644 test/visual/editor_image_tools_test.dart create mode 100644 test/visual/share_image_editor_tools_test.dart diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 691daba7..51216225 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -36,6 +36,14 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { Expression get _visibleInChat => 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 get _hasPreviewableContent => + messages.type.equals(MessageType.media.name).not() | + mediaFiles.mediaId.isNotNull() | + messages.isDeletedFromSender.equals(true); + Stream> watchMessageNotOpened(String groupId) { final query = select(messages).join([ @@ -48,6 +56,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messages.openedAt.isNull() & messages.isWidgetMedia.equals(false) & _visibleInChat & + _hasPreviewableContent & messages.groupId.equals(groupId) & messages.isDeletedFromSender.equals(false) & (messages.mediaId.isNull() | @@ -72,6 +81,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messages.openedAt.isNull() & messages.isWidgetMedia.equals(false) & _visibleInChat & + _hasPreviewableContent & messages.isDeletedFromSender.equals(false) & (messages.mediaId.isNull() | mediaFiles.downloadState.isNull() | @@ -101,6 +111,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messages.isWidgetMedia.equals(false) & _visibleInChat & messages.groupId.equals(groupId) & + messages.isDeletedFromSender.equals(false) & messages.mediaId.isNotNull() & messages.senderId.isNotNull() & messages.type.equals(MessageType.media.name), @@ -146,6 +157,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ..where( messages.groupId.equals(groupId) & _visibleInChat & + _hasPreviewableContent & // 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 (messages.openedAt.isBiggerThanValue(deletionTime) | @@ -180,6 +192,11 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { INNER JOIN groups ON groups.group_id = messages.group_id LEFT JOIN media_files ON media_files.media_id = messages.media_id 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 ( messages.opened_at IS NULL OR messages.media_stored = 1 OR diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 5002dd2f..d28b5295 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1294,6 +1294,30 @@ abstract class AppLocalizations { /// **'Emoji'** 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. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_ar.dart b/lib/src/localization/generated/app_localizations_ar.dart index 7ea5531f..e61454a5 100644 --- a/lib/src/localization/generated/app_localizations_ar.dart +++ b/lib/src/localization/generated/app_localizations_ar.dart @@ -689,6 +689,19 @@ class AppLocalizationsAr extends AppLocalizations { @override String get addEmoji => 'رمز تعبيري'; + @override + String get drawingColorPicker => 'اختيار لون من الصورة'; + + @override + String get createStickerFromImage => 'إنشاء ملصق من الصورة'; + + @override + String get stickerSelectionHint => + 'حرّك الإطار أو غيّر حجمه حول الجزء الذي تريده كملصق.'; + + @override + String get stickerCreating => 'جارٍ إنشاء الملصق…'; + @override String get toggleFlashLight => 'تشغيل الفلاش أو إيقافه'; diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 3db53f71..09ea08c6 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -683,6 +683,19 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get toggleFlashLight => 'Taschenlampe umschalten'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 39128db9..bcf64589 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -680,6 +680,19 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get toggleFlashLight => 'Toggle the flash light'; diff --git a/lib/src/localization/translations/ar.arb b/lib/src/localization/translations/ar.arb index 041e3f33..c82fd351 100644 --- a/lib/src/localization/translations/ar.arb +++ b/lib/src/localization/translations/ar.arb @@ -199,6 +199,10 @@ "protectAsARealTwonly": "إرسال كرسالة twonly حقيقية!", "addDrawing": "رسم", "addEmoji": "رمز تعبيري", + "drawingColorPicker": "اختيار لون من الصورة", + "createStickerFromImage": "إنشاء ملصق من الصورة", + "stickerSelectionHint": "حرّك الإطار أو غيّر حجمه حول الجزء الذي تريده كملصق.", + "stickerCreating": "جارٍ إنشاء الملصق…", "toggleFlashLight": "تشغيل الفلاش أو إيقافه", "userFound": "تم العثور على {username}", "userFoundBody": "هل تريد التواصل مع {username}؟", diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 2ff097e6..4462ff6f 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -242,6 +242,10 @@ "protectAsARealTwonly": "Als echtes twonly senden!", "addDrawing": "Zeichnung", "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", "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.", diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index d6ccaab9..4225db31 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -243,6 +243,10 @@ "protectAsARealTwonly": "Send as real twonly!", "addDrawing": "Drawing", "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", "userFound": "{username} found", "userFoundBody": "Do you want to connect with {username}?", diff --git a/lib/src/services/stickers/sticker.service.dart b/lib/src/services/stickers/sticker.service.dart index e05a2326..87952b5e 100644 --- a/lib/src/services/stickers/sticker.service.dart +++ b/lib/src/services/stickers/sticker.service.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -76,6 +77,22 @@ class StickerService { 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 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 /// cloud-only memory is downloaded before its path is returned. static Future resolveMediaPath(MediaFileService media) async { diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index 96b1aba5..77d11df2 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -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/model/protobuf/client/generated/data.pb.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/utils/keyvalue.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/screenshot.helper.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/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_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_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_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/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/widget_share_explainer.dart'; import 'package:video_player/video_player.dart'; @@ -104,6 +108,8 @@ class _ShareImageEditorView extends State { bool _previousMediaSettingsCaptured = false; int? _displayLimitBeforeWidget; bool _requiresAuthBeforeWidget = false; + bool _selectingSticker = false; + bool _creatingSticker = false; bool get _showWidgetExplainer => _widgetRecipientAvailable && @@ -438,6 +444,80 @@ class _ShareImageEditorView extends State { ); } + Future _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 _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 _onClosePressed() async { if (!layerStack.hasUserAddedLayers) { Navigator.pop(context, false); @@ -566,6 +646,11 @@ class _ShareImageEditorView extends State { canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; + if (_creatingSticker) return; + if (_selectingSticker) { + _cancelStickerSelection(); + return; + } await askToCloseThenClose(); }, child: Scaffold( @@ -583,21 +668,24 @@ class _ShareImageEditorView extends State { tabDownPosition = details.globalPosition.dy; } }, - onTap: _onCanvasTap, + onTap: _selectingSticker ? null : _onCanvasTap, child: MediaViewSizingHelper.cameraEditor( - bottomNavigation: EditorBottomBar( - mediaService: mediaService, - sendToGroup: widget.sendToGroup, - isLoadingImage: loadingImage, - isSending: sendingOrLoadingImage || _updatingWidgetMode, - storeImageAsOriginal: storeImageAsOriginal, - onAddMoreRecipients: pushShareImageView, - onSend: () async { - if (widget.sendToGroup == null) { - return pushShareImageView(); - } - await sendImageToSinglePerson(); - }, + bottomNavigation: IgnorePointer( + ignoring: _selectingSticker, + child: EditorBottomBar( + mediaService: mediaService, + sendToGroup: widget.sendToGroup, + isLoadingImage: loadingImage, + isSending: sendingOrLoadingImage || _updatingWidgetMode, + storeImageAsOriginal: storeImageAsOriginal, + onAddMoreRecipients: pushShareImageView, + onSend: () async { + if (widget.sendToGroup == null) { + return pushShareImageView(); + } + await sendImageToSinglePerson(); + }, + ), ), child: EditorCanvas( layerStack: layerStack, @@ -620,22 +708,29 @@ class _ShareImageEditorView extends State { onChangeEnd: _persistTrim, ) : null, + stickerSelectionActive: _selectingSticker, + stickerSelectionBusy: _creatingSticker, + onCancelStickerSelection: _cancelStickerSelection, + onConfirmStickerSelection: _createStickerFromSelection, ), ), ), - Positioned( - top: 10, - left: 5, - right: 0, - child: SafeArea( - child: EditorTopToolbar( - layerStack: layerStack, - onClose: _onClosePressed, - onChanged: () => setState(() {}), + if (!_selectingSticker) + Positioned( + top: 10, + left: 5, + right: 0, + child: SafeArea( + child: EditorTopToolbar( + layerStack: layerStack, + onClose: _onClosePressed, + onChanged: () => setState(() {}), + ), ), ), - ), - if (_showWidgetExplainer && widgetActionCenter != null) + if (!_selectingSticker && + _showWidgetExplainer && + widgetActionCenter != null) Positioned( left: math.max( 12, @@ -650,35 +745,40 @@ class _ShareImageEditorView extends State { ), ), ), - Positioned( - right: 6, - top: 100, - child: Container( - alignment: Alignment.bottomCenter, - padding: const EdgeInsets.symmetric(vertical: 16), - child: SafeArea( - child: EditorSideToolbar( - layerStack: layerStack, - mediaService: mediaService, - onChanged: () => setState(() {}), - onEditDisplayTime: _editDisplayTime, - onToggleAudio: _toggleAudio, - onToggleRequiresAuth: _toggleRequiresAuth, - sendToWidget: _sendToWidget, - showWidgetOption: _widgetRecipientAvailable, - highlightWidgetOption: _showWidgetExplainer, - isUpdatingWidgetMode: _updatingWidgetMode, - widgetActionKey: _widgetActionKey, - onToggleSendToWidget: () => - _setSendToWidget(!_sendToWidget), - canTrim: _canTrim, - trimmerVisible: _trimmerVisible, - onToggleTrimmer: () => - setState(() => _trimmerVisible = !_trimmerVisible), + if (!_selectingSticker) + Positioned( + right: 6, + top: 100, + child: Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.symmetric(vertical: 16), + child: SafeArea( + child: EditorSideToolbar( + layerStack: layerStack, + mediaService: mediaService, + onChanged: () => setState(() {}), + onEditDisplayTime: _editDisplayTime, + onToggleAudio: _toggleAudio, + onToggleRequiresAuth: _toggleRequiresAuth, + sendToWidget: _sendToWidget, + showWidgetOption: _widgetRecipientAvailable, + highlightWidgetOption: _showWidgetExplainer, + isUpdatingWidgetMode: _updatingWidgetMode, + widgetActionKey: _widgetActionKey, + onToggleSendToWidget: () => + _setSendToWidget(!_sendToWidget), + colorSamplerLoader: media.type == MediaType.image + ? _loadEditorColorSampler + : null, + onCreateStickerFromImage: _startStickerSelection, + canTrim: _canTrim, + trimmerVisible: _trimmerVisible, + onToggleTrimmer: () => + setState(() => _trimmerVisible = !_trimmerVisible), + ), ), ), ), - ), ], ), ), diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart index 5dd6f806..70a27af6 100644 --- a/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart @@ -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/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/sticker_cutout_selector.dart'; import 'package:video_player/video_player.dart'; /// The media itself with all editor layers stacked on top of it. @@ -19,6 +20,10 @@ class EditorCanvas extends StatelessWidget { required this.onLayersUpdated, this.videoController, this.bottomOverlay, + this.stickerSelectionActive = false, + this.stickerSelectionBusy = false, + this.onCancelStickerSelection, + this.onConfirmStickerSelection, super.key, }); @@ -34,6 +39,11 @@ class EditorCanvas extends StatelessWidget { /// 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. final Widget? bottomOverlay; + final bool stickerSelectionActive; + final bool stickerSelectionBusy; + final VoidCallback? onCancelStickerSelection; + final Future Function(Rect selection, Size editorSize)? + onConfirmStickerSelection; bool get _hasVideo => videoController != null && videoController!.value.isInitialized; @@ -76,6 +86,14 @@ class EditorCanvas extends StatelessWidget { _buildLayers(), if (bottomOverlay != null) Positioned(left: 0, right: 0, bottom: 0, child: bottomOverlay!), + if (stickerSelectionActive) + Positioned.fill( + child: StickerCutoutSelector( + busy: stickerSelectionBusy, + onCancel: onCancelStickerSelection!, + onConfirm: onConfirmStickerSelection!, + ), + ), ], ), ); diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_image_tools.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_image_tools.dart new file mode 100644 index 00000000..0f3910f9 --- /dev/null +++ b/lib/src/visual/views/camera/share_image_editor_components/editor_image_tools.dart @@ -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 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); +} diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart index f8851217..4b72921d 100644 --- a/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart @@ -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. void toggleBackgroundEditing() { diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart index a3b6f708..ff236487 100644 --- a/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart @@ -39,22 +39,29 @@ class EditorMediaWriter { final unmodified = layerStack.unmodifiedBackgroundImage; 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 captureCanvasImage(double pixelRatio) async { layerStack.setCustomButtonsVisible(visible: false); requestRebuild(); - // Make a short delay, so the rebuild does have its effect... - await Future.delayed(const Duration(milliseconds: 80)); - - final image = await screenshotController.capture(pixelRatio: pixelRatio); - if (image == null) { - Log.warn('screenshotController did not return image bytes'); - return null; + try { + // Give the frame without editor controls time to reach the boundary. + await Future.delayed(const Duration(milliseconds: 80)); + final image = await screenshotController.capture(pixelRatio: pixelRatio); + if (image == null) { + Log.warn('screenshotController did not return image bytes'); + } + 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 diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart index af0219b7..7f2f446c 100644 --- a/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart @@ -29,6 +29,8 @@ class EditorSideToolbar extends StatelessWidget { required this.isUpdatingWidgetMode, required this.widgetActionKey, required this.onToggleSendToWidget, + required this.colorSamplerLoader, + required this.onCreateStickerFromImage, super.key, }); @@ -52,6 +54,8 @@ class EditorSideToolbar extends StatelessWidget { final bool isUpdatingWidgetMode; final GlobalKey widgetActionKey; final VoidCallback onToggleSendToWidget; + final EditorColorSamplerLoader? colorSamplerLoader; + final VoidCallback onCreateStickerFromImage; MediaFile get media => mediaService.mediaFile; @@ -95,7 +99,7 @@ class EditorSideToolbar extends StatelessWidget { Icons.draw_rounded, tooltipText: context.lang.addDrawing, onPressed: () { - layerStack.addDrawLayer(); + layerStack.addDrawLayer(colorSamplerLoader: colorSamplerLoader); onChanged(); }, ), @@ -167,6 +171,14 @@ class EditorSideToolbar extends StatelessWidget { ), ], 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), ActionButton( Icons.crop_rotate_outlined, diff --git a/lib/src/visual/views/camera/share_image_editor_components/layer_data.dart b/lib/src/visual/views/camera/share_image_editor_components/layer_data.dart index 49c75135..8faf7567 100755 --- a/lib/src/visual/views/camera/share_image_editor_components/layer_data.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/layer_data.dart @@ -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/layers/link_preview/parser/base.dart'; +typedef EditorColorSampler = Color? Function(Offset position, Size editorSize); +typedef EditorColorSamplerLoader = Future Function(); + /// Layer class with some common properties class Layer { Layer({ @@ -111,6 +114,7 @@ class TextLayerData extends Layer { class DrawLayerData extends Layer { DrawLayerData({ required super.key, + this.colorSamplerLoader, super.offset, super.opacity, super.rotation, @@ -118,6 +122,7 @@ class DrawLayerData extends Layer { super.hasCustomActionButtons = true, super.isEditing = true, }); + final EditorColorSamplerLoader? colorSamplerLoader; final control = HandSignatureControl( // ignore: prefer_const_constructors setup: () => SignaturePathSetup( diff --git a/lib/src/visual/views/camera/share_image_editor_components/layers/draw.layer.dart b/lib/src/visual/views/camera/share_image_editor_components/layers/draw.layer.dart index d116e9ca..3bf75ec3 100644 --- a/lib/src/visual/views/camera/share_image_editor_components/layers/draw.layer.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/layers/draw.layer.dart @@ -25,6 +25,11 @@ class _DrawLayerState extends State { List undoList = []; bool skipNextEvent = false; bool showMagnifyingGlass = false; + bool _eyedropperActive = false; + bool _loadingColorSampler = false; + Offset? _eyedropperPosition; + Color? _previewColor; + EditorColorSampler? _colorSampler; @override void initState() { @@ -75,6 +80,55 @@ class _DrawLayerState extends State { }); } + Future _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 Widget build(BuildContext context) { return Stack( @@ -88,6 +142,24 @@ class _DrawLayerState extends State { 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) Positioned( top: 5, @@ -137,6 +209,7 @@ class _DrawLayerState extends State { ), if (widget.layerData.isEditing && widget.layerData.showCustomButtons) Positioned( + key: const Key('drawingColorSlider'), right: 20, top: 50, child: Stack( @@ -180,6 +253,8 @@ class _DrawLayerState extends State { onChanged: _onSliderChanged, onChangeStart: (value) => { setState(() { + _eyedropperPosition = null; + _previewColor = null; showMagnifyingGlass = true; }), }, @@ -195,11 +270,39 @@ class _DrawLayerState extends State { ], ), ), + 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) Positioned( - right: 80, - top: 50 + (185 * _sliderValue), - child: MagnifyingGlass(color: currentColor), + right: _eyedropperPosition == null ? 80 : null, + left: _eyedropperPosition == null + ? 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) // Positioned.fill( diff --git a/lib/src/visual/views/camera/share_image_editor_components/sticker_cutout_selector.dart b/lib/src/visual/views/camera/share_image_editor_components/sticker_cutout_selector.dart new file mode 100644 index 00000000..3c99754a --- /dev/null +++ b/lib/src/visual/views/camera/share_image_editor_components/sticker_cutout_selector.dart @@ -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 Function(Rect selection, Size editorSize) onConfirm; + + @override + State createState() => _StickerCutoutSelectorState(); +} + +class _StickerCutoutSelectorState extends State { + 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; +} diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index 2975882d..b4794c95 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -271,6 +271,7 @@ class _UserListItem extends State { .toList(); if (msgs.isNotEmpty && msgs.first.type == MessageType.media.name && + msgs.first.mediaId != null && !msgs.first.isDeletedFromSender && msgs.first.senderId != null && msgs.first.openedAt == null) { @@ -320,19 +321,23 @@ class _UserListItem extends State { } if (_hasNonOpenedMediaFile) { - final msgs = _previewMessages - .where((x) => x.type == MessageType.media.name && !x.isWidgetMedia) - .toList(); - final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( - msgs.first.mediaId!, - ); - if (mediaFile?.type != MediaType.audio) { - if (mediaFile?.downloadState == null) return; - if (mediaFile!.downloadState! == DownloadState.pending) { + final message = _previewMessages + .where( + (x) => + x.type == MessageType.media.name && + !x.isWidgetMedia && + x.mediaId != null, + ) + .firstOrNull; + 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); return; } - if (mediaFile.downloadState! == DownloadState.ready) { + if (mediaFile.downloadState == DownloadState.ready) { if (!mounted) return; await context.push( Routes.chatsMediaViewer, diff --git a/test/services/messages_stream_test.dart b/test/services/messages_stream_test.dart index 69febf1c..9cf7e83b 100644 --- a/test/services/messages_stream_test.dart +++ b/test/services/messages_stream_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNotNull, isNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.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/twonly.db.dart'; import 'package:twonly/src/services/user.service.dart'; @@ -146,4 +147,78 @@ void main() { 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().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', + ); + }, + ); } diff --git a/test/visual/editor_image_tools_test.dart b/test/visual/editor_image_tools_test.dart new file mode 100644 index 00000000..25d2635f --- /dev/null +++ b/test/visual/editor_image_tools_test.dart @@ -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); + }); +} diff --git a/test/visual/share_image_editor_tools_test.dart b/test/visual/share_image_editor_tools_test.dart new file mode 100644 index 00000000..2e54ea7d --- /dev/null +++ b/test/visual/share_image_editor_tools_test.dart @@ -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(find.byType(MagnifyingGlass)).color, + Colors.blue, + ); + expect( + tester + .widget(find.byType(CustomHandSignature)) + .currentColor, + Colors.red, + ); + + await gesture.moveTo(targetTopLeft + const Offset(220, 300)); + await tester.pump(); + expect( + tester.widget(find.byType(MagnifyingGlass)).color, + Colors.teal, + ); + expect( + tester + .widget(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(find.byType(CustomHandSignature)) + .currentColor, + Colors.teal, + ); + expect(find.byKey(const Key('drawingEyedropperTarget')), findsNothing); + }); +}