crop images and small improvements
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-04-06 13:35:36 +02:00
parent 8810ecf360
commit b7e6cbfc2f
10 changed files with 227 additions and 124 deletions

View file

@ -2,8 +2,10 @@
## 0.1.3 ## 0.1.3
- New: Developer settings to reduce flames - New: Video stabilization
- New: Crop or rotate images before share them
- New: Clicking on “Text Notifications” will now open the chat directly (Android only) - New: Clicking on “Text Notifications” will now open the chat directly (Android only)
- New: Developer settings to reduce flames
- Improve: Improved troubleshooting for issues with push notifications - Improve: Improved troubleshooting for issues with push notifications
- Fix: Flash not activated when starting a video recording - Fix: Flash not activated when starting a video recording
- Fix: Problem sending media when a recipient has deleted their account. - Fix: Problem sending media when a recipient has deleted their account.

View file

@ -26,19 +26,19 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
Log.error('Did not update reaction as it is not an emoji!'); Log.error('Did not update reaction as it is not an emoji!');
return; return;
} }
final msg = final msg = await twonlyDB.messagesDao
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull(); .getMessageById(messageId)
.getSingleOrNull();
if (msg == null || msg.groupId != groupId) return; if (msg == null || msg.groupId != groupId) return;
try { try {
if (remove) { if (remove) {
await (delete(reactions) await (delete(reactions)..where(
..where( (t) =>
(t) => t.senderId.equals(contactId) &
t.senderId.equals(contactId) & t.messageId.equals(messageId) &
t.messageId.equals(messageId) & t.emoji.equals(emoji),
t.emoji.equals(emoji), ))
))
.go(); .go();
} else { } else {
await into(reactions).insertOnConflictUpdate( await into(reactions).insertOnConflictUpdate(
@ -63,18 +63,18 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
Log.error('Did not update reaction as it is not an emoji!'); Log.error('Did not update reaction as it is not an emoji!');
return; return;
} }
final msg = final msg = await twonlyDB.messagesDao
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull(); .getMessageById(messageId)
.getSingleOrNull();
if (msg == null) return; if (msg == null) return;
try { try {
await (delete(reactions) await (delete(reactions)..where(
..where( (t) =>
(t) => t.senderId.isNull() &
t.senderId.isNull() & t.messageId.equals(messageId) &
t.messageId.equals(messageId) & t.emoji.equals(emoji),
t.emoji.equals(emoji), ))
))
.go(); .go();
if (!remove) { if (!remove) {
await into(reactions).insert( await into(reactions).insert(
@ -98,20 +98,19 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
} }
Stream<Reaction?> watchLastReactions(String groupId) { Stream<Reaction?> watchLastReactions(String groupId) {
final query = (select(reactions) final query =
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) (select(reactions)).join(
.join( [
[ innerJoin(
innerJoin( messages,
messages, messages.messageId.equalsExp(reactions.messageId),
messages.messageId.equalsExp(reactions.messageId), useColumns: false,
useColumns: false, ),
), ],
], )
) ..where(messages.groupId.equals(groupId))
..where(messages.groupId.equals(groupId)) ..orderBy([OrderingTerm.desc(messages.createdAt)])
// ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) ..limit(1);
..limit(1);
return query.map((row) => row.readTable(reactions)).watchSingleOrNull(); return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
} }

View file

@ -53,8 +53,8 @@ class UserData {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool requestedAudioPermission = false; bool requestedAudioPermission = false;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: true)
bool videoStabilizationEnabled = false; bool videoStabilizationEnabled = true;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool showFeedbackShortcut = true; bool showFeedbackShortcut = true;

View file

@ -4,6 +4,7 @@ import 'package:camera/camera.dart';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart'; import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
@ -133,7 +134,7 @@ class MainCameraController {
await cameraController?.initialize(); await cameraController?.initialize();
await cameraController?.startImageStream(_processCameraImage); await cameraController?.startImageStream(_processCameraImage);
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
if (gUser.videoStabilizationEnabled) { if (gUser.videoStabilizationEnabled && !kDebugMode) {
await cameraController?.setVideoStabilizationMode( await cameraController?.setVideoStabilizationMode(
VideoStabilizationMode.level1, VideoStabilizationMode.level1,
); );

View file

@ -109,13 +109,21 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
sendingOrLoadingImage = false; sendingOrLoadingImage = false;
loadingImage = false; loadingImage = false;
}); });
videoController = VideoPlayerController.file(mediaService.originalPath); videoController = VideoPlayerController.file(
mediaService.originalPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
);
videoController?.setLooping(true); videoController?.setLooping(true);
videoController?.initialize().then((_) async { videoController
await videoController!.play(); ?.initialize()
setState(() {}); .then((_) async {
// ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler await videoController!.play();
}).catchError(Log.error); setState(() {});
})
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
.catchError(Log.error);
} }
} }
@ -205,8 +213,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheRight { List<Widget> get actionsAtTheRight {
if (layers.isNotEmpty && if (layers.isNotEmpty &&
layers.last.isEditing && (layers.first.isEditing ||
layers.last.hasCustomActionButtons) { (layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return []; return [];
} }
return <Widget>[ return <Widget>[
@ -246,13 +254,15 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Icons.add_reaction_outlined, Icons.add_reaction_outlined,
tooltipText: context.lang.addEmoji, tooltipText: context.lang.addEmoji,
onPressed: () async { onPressed: () async {
final layer = await showModalBottomSheet( final layer =
context: context, await showModalBottomSheet(
backgroundColor: Colors.black, context: context,
builder: (context) { backgroundColor: Colors.black,
return const EmojiPickerBottom(); builder: (context) {
}, return const EmojiPickerBottom();
) as Layer?; },
)
as Layer?;
if (layer == null) return; if (layer == null) return;
undoLayers.clear(); undoLayers.clear();
removedLayers.clear(); removedLayers.clear();
@ -265,19 +275,20 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
count: (media.type == MediaType.video) count: (media.type == MediaType.video)
? '0' ? '0'
: media.displayLimitInMilliseconds == null : media.displayLimitInMilliseconds == null
? '' ? ''
: (media.displayLimitInMilliseconds! ~/ 1000).toString(), : (media.displayLimitInMilliseconds! ~/ 1000).toString(),
child: ActionButton( child: ActionButton(
(media.type == MediaType.video) (media.type == MediaType.video)
? media.displayLimitInMilliseconds == null ? media.displayLimitInMilliseconds == null
? Icons.repeat_rounded ? Icons.repeat_rounded
: Icons.repeat_one_rounded : Icons.repeat_one_rounded
: Icons.timer_outlined, : Icons.timer_outlined,
tooltipText: context.lang.protectAsARealTwonly, tooltipText: context.lang.protectAsARealTwonly,
onPressed: _setImageDisplayTime, onPressed: _setImageDisplayTime,
), ),
), ),
if (media.type == MediaType.video) if (media.type == MediaType.video) ...[
const SizedBox(height: 8),
ActionButton( ActionButton(
(mediaService.removeAudio) (mediaService.removeAudio)
? Icons.volume_off_rounded ? Icons.volume_off_rounded
@ -296,6 +307,29 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (mounted) setState(() {}); if (mounted) setState(() {});
}, },
), ),
],
if (media.type == MediaType.image) ...[
const SizedBox(height: 8),
ActionButton(
Icons.crop_rotate_outlined,
tooltipText: 'Crop or rotate image',
color: Colors.white,
onPressed: () async {
final first = layers.first;
if (first is BackgroundLayerData) {
first.isEditing = !first.isEditing;
}
setState(() {});
// await mediaService.toggleRemoveAudio();
// if (mediaService.removeAudio) {
// await videoController?.setVolume(0);
// } else {
// await videoController?.setVolume(100);
// }
// if (mounted) setState(() {});
},
),
],
const SizedBox(height: 8), const SizedBox(height: 8),
ActionButton( ActionButton(
FontAwesomeIcons.shieldHeart, FontAwesomeIcons.shieldHeart,
@ -348,8 +382,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheTop { List<Widget> get actionsAtTheTop {
if (layers.isNotEmpty && if (layers.isNotEmpty &&
layers.last.isEditing && (layers.first.isEditing ||
layers.last.hasCustomActionButtons) { (layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return []; return [];
} }
return [ return [
@ -411,18 +445,20 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await videoController?.pause(); await videoController?.pause();
if (isDisposed || !mounted) return; if (isDisposed || !mounted) return;
final wasSend = await Navigator.push( final wasSend =
context, await Navigator.push(
MaterialPageRoute( context,
builder: (context) => ShareImageView( MaterialPageRoute(
selectedGroupIds: selectedGroupIds, builder: (context) => ShareImageView(
updateSelectedGroupIds: updateSelectedGroupIds, selectedGroupIds: selectedGroupIds,
mediaStoreFuture: mediaStoreFuture, updateSelectedGroupIds: updateSelectedGroupIds,
mediaFileService: mediaService, mediaStoreFuture: mediaStoreFuture,
additionalData: getAdditionalData(), mediaFileService: mediaService,
), additionalData: getAdditionalData(),
), ),
) as bool?; ),
)
as bool?;
if (wasSend != null && wasSend && mounted) { if (wasSend != null && wasSend && mounted) {
widget.mainCameraController?.onImageSend(); widget.mainCameraController?.onImageSend();
Navigator.pop(context, true); Navigator.pop(context, true);
@ -552,8 +588,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
}); });
// It is important that the user can sending the image only when the image is fully loaded otherwise if the user // It is important that the user can sending the image only when the image is fully loaded otherwise if the user
// will click on send before the image is painted the screenshot will be transparent.. // will click on send before the image is painted the screenshot will be transparent..
_imageLoadingTimer = _imageLoadingTimer = Timer.periodic(const Duration(milliseconds: 10), (
Timer.periodic(const Duration(milliseconds: 10), (timer) { timer,
) {
final imageLayer = layers.first; final imageLayer = layers.first;
if (imageLayer is BackgroundLayerData) { if (imageLayer is BackgroundLayerData) {
if (imageLayer.imageLoaded) { if (imageLayer.imageLoaded) {
@ -619,8 +656,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await askToCloseThenClose(); await askToCloseThenClose();
}, },
child: Scaffold( child: Scaffold(
backgroundColor: backgroundColor: widget.sharedFromGallery
widget.sharedFromGallery ? null : Colors.white.withAlpha(0), ? null
: Colors.white.withAlpha(0),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -667,8 +705,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
OutlinedButton( OutlinedButton(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
iconColor: Theme.of(context).colorScheme.primary, iconColor: Theme.of(context).colorScheme.primary,
foregroundColor: foregroundColor: Theme.of(
Theme.of(context).colorScheme.primary, context,
).colorScheme.primary,
), ),
onPressed: pushShareImageView, onPressed: pushShareImageView,
child: const FaIcon(FontAwesomeIcons.userPlus), child: const FaIcon(FontAwesomeIcons.userPlus),
@ -681,9 +720,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
width: 12, width: 12,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Theme.of(context) color: Theme.of(
.colorScheme context,
.inversePrimary, ).colorScheme.inversePrimary,
), ),
) )
: const FaIcon(FontAwesomeIcons.solidPaperPlane), : const FaIcon(FontAwesomeIcons.solidPaperPlane),

View file

@ -1,6 +1,10 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
class BackgroundLayer extends StatefulWidget { class BackgroundLayer extends StatefulWidget {
@ -29,14 +33,47 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scImage = widget.layerData.image.image; final scImage = widget.layerData.image.image;
if (scImage == null || scImage.image == null) return Container(); if (scImage == null || scImage.image == null) return Container();
return Container( return Stack(
width: widget.layerData.image.width.toDouble(), children: [
height: widget.layerData.image.height.toDouble(), Positioned.fill(
padding: EdgeInsets.zero, child: PhotoView.customChild(
color: Colors.transparent, enableRotation: true,
child: CustomPaint( initialScale: PhotoViewComputedScale.contained,
painter: UiImagePainter(scImage.image!), minScale: PhotoViewComputedScale.contained,
), backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
child: Container(
width: widget.layerData.image.width.toDouble(),
height: widget.layerData.image.height.toDouble(),
padding: EdgeInsets.zero,
color: Colors.transparent,
child: CustomPaint(
painter: UiImagePainter(scImage.image!),
),
),
),
),
if (widget.layerData.isEditing)
Positioned(
top: 5,
left: 5,
right: 50,
child: Row(
children: [
ActionButton(
FontAwesomeIcons.check,
tooltipText: context.lang.imageEditorDrawOk,
onPressed: () async {
widget.layerData.isEditing = false;
widget.onUpdate!();
setState(() {});
},
),
],
),
),
],
); );
} }
} }

View file

@ -23,11 +23,15 @@ class LayersViewer extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
...layers.whereType<BackgroundLayerData>().map((layerItem) { ...layers.whereType<BackgroundLayerData>().map((layerItem) {
return BackgroundLayer( if (!layerItem.isEditing) {
key: layerItem.key, return BackgroundLayer(
layerData: layerItem, key: layerItem.key,
onUpdate: onUpdate, layerData: layerItem,
); onUpdate: onUpdate,
);
} else {
return Container();
}
}), }),
...layers.whereType<FilterLayerData>().map((layerItem) { ...layers.whereType<FilterLayerData>().map((layerItem) {
return FilterLayer( return FilterLayer(
@ -37,39 +41,50 @@ class LayersViewer extends StatelessWidget {
}), }),
...layers ...layers
.where( .where(
(layerItem) => (layerItem) =>
layerItem is EmojiLayerData || layerItem is EmojiLayerData ||
layerItem is DrawLayerData || layerItem is DrawLayerData ||
layerItem is LinkPreviewLayerData || layerItem is LinkPreviewLayerData ||
layerItem is TextLayerData, layerItem is TextLayerData,
) )
.map((layerItem) { .map((layerItem) {
if (layerItem is EmojiLayerData) { if (layerItem is EmojiLayerData) {
return EmojiLayer( return EmojiLayer(
key: layerItem.key, key: layerItem.key,
layerData: layerItem, layerData: layerItem,
onUpdate: onUpdate, onUpdate: onUpdate,
); );
} else if (layerItem is DrawLayerData) { } else if (layerItem is DrawLayerData) {
return DrawLayer( return DrawLayer(
key: layerItem.key, key: layerItem.key,
layerData: layerItem, layerData: layerItem,
onUpdate: onUpdate, onUpdate: onUpdate,
); );
} else if (layerItem is TextLayerData) { } else if (layerItem is TextLayerData) {
return TextLayer( return TextLayer(
key: layerItem.key, key: layerItem.key,
layerData: layerItem, layerData: layerItem,
onUpdate: onUpdate, onUpdate: onUpdate,
); );
} else if (layerItem is LinkPreviewLayerData) { } else if (layerItem is LinkPreviewLayerData) {
return LinkPreviewLayer( return LinkPreviewLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
}
return Container();
}),
...layers.whereType<BackgroundLayerData>().map((layerItem) {
if (layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key, key: layerItem.key,
layerData: layerItem, layerData: layerItem,
onUpdate: onUpdate, onUpdate: onUpdate,
); );
} else {
return Container();
} }
return Container();
}), }),
], ],
); );

View file

@ -75,9 +75,6 @@ class _UserListItem extends State<GroupListItem> {
setState(() { setState(() {
_lastReaction = update; _lastReaction = update;
}); });
// protectUpdateState.protect(() async {
// await updateState(lastMessage, update, messagesNotOpened);
// });
}); });
_messagesNotOpenedStream = twonlyDB.messagesDao _messagesNotOpenedStream = twonlyDB.messagesDao

View file

@ -287,7 +287,15 @@ class _MediaViewerViewState extends State<MediaViewerView> {
var timerRequired = false; var timerRequired = false;
if (currentMediaLocal.mediaFile.type == MediaType.video) { if (currentMediaLocal.mediaFile.type == MediaType.video) {
videoController = VideoPlayerController.file(currentMediaLocal.tempPath); videoController = VideoPlayerController.file(
currentMediaLocal.tempPath,
videoPlayerOptions: VideoPlayerOptions(
// only mix in case the video can be played multiple times,
// otherwise stop the background music in case the video contains audio
mixWithOthers:
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
),
);
await videoController?.setLooping( await videoController?.setLooping(
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
); );

View file

@ -21,7 +21,12 @@ class _VideoPlayerWrapperState extends State<VideoPlayerWrapper> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = VideoPlayerController.file(widget.videoPath); _controller = VideoPlayerController.file(
widget.videoPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
);
unawaited( unawaited(
_controller.initialize().then((_) async { _controller.initialize().then((_) async {