improve image editor

This commit is contained in:
otsmr 2026-10-06 22:52:57 +02:00
parent 7b5ca07e2f
commit 3efaea3175
22 changed files with 1229 additions and 78 deletions

View file

@ -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

View file

@ -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:

View file

@ -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 => 'تشغيل الفلاش أو إيقافه';

View file

@ -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';

View file

@ -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';

View file

@ -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}؟",

View file

@ -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.",

View file

@ -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}?",

View file

@ -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 {

View file

@ -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),
),
), ),
), ),
), ),
),
], ],
), ),
), ),

View file

@ -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!,
),
),
], ],
), ),
); );

View file

@ -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);
}

View file

@ -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() {

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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;
}

View file

@ -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,

View file

@ -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',
);
},
);
} }

View 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);
});
}

View 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);
});
}