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
- 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: Developer settings to reduce flames
- Improve: Improved troubleshooting for issues with push notifications
- Fix: Flash not activated when starting a video recording
- 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!');
return;
}
final msg =
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull();
final msg = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (msg == null || msg.groupId != groupId) return;
try {
if (remove) {
await (delete(reactions)
..where(
(t) =>
t.senderId.equals(contactId) &
t.messageId.equals(messageId) &
t.emoji.equals(emoji),
))
await (delete(reactions)..where(
(t) =>
t.senderId.equals(contactId) &
t.messageId.equals(messageId) &
t.emoji.equals(emoji),
))
.go();
} else {
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!');
return;
}
final msg =
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull();
final msg = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (msg == null) return;
try {
await (delete(reactions)
..where(
(t) =>
t.senderId.isNull() &
t.messageId.equals(messageId) &
t.emoji.equals(emoji),
))
await (delete(reactions)..where(
(t) =>
t.senderId.isNull() &
t.messageId.equals(messageId) &
t.emoji.equals(emoji),
))
.go();
if (!remove) {
await into(reactions).insert(
@ -98,20 +98,19 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
}
Stream<Reaction?> watchLastReactions(String groupId) {
final query = (select(reactions)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.join(
[
innerJoin(
messages,
messages.messageId.equalsExp(reactions.messageId),
useColumns: false,
),
],
)
..where(messages.groupId.equals(groupId))
// ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
..limit(1);
final query =
(select(reactions)).join(
[
innerJoin(
messages,
messages.messageId.equalsExp(reactions.messageId),
useColumns: false,
),
],
)
..where(messages.groupId.equals(groupId))
..orderBy([OrderingTerm.desc(messages.createdAt)])
..limit(1);
return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import 'dart:ui' as ui;
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';
class BackgroundLayer extends StatefulWidget {
@ -29,14 +33,47 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
Widget build(BuildContext context) {
final scImage = widget.layerData.image.image;
if (scImage == null || scImage.image == null) return Container();
return 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!),
),
return Stack(
children: [
Positioned.fill(
child: PhotoView.customChild(
enableRotation: true,
initialScale: PhotoViewComputedScale.contained,
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,
children: [
...layers.whereType<BackgroundLayerData>().map((layerItem) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
if (!layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else {
return Container();
}
}),
...layers.whereType<FilterLayerData>().map((layerItem) {
return FilterLayer(
@ -37,39 +41,50 @@ class LayersViewer extends StatelessWidget {
}),
...layers
.where(
(layerItem) =>
layerItem is EmojiLayerData ||
layerItem is DrawLayerData ||
layerItem is LinkPreviewLayerData ||
layerItem is TextLayerData,
)
(layerItem) =>
layerItem is EmojiLayerData ||
layerItem is DrawLayerData ||
layerItem is LinkPreviewLayerData ||
layerItem is TextLayerData,
)
.map((layerItem) {
if (layerItem is EmojiLayerData) {
return EmojiLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is DrawLayerData) {
return DrawLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is TextLayerData) {
return TextLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is LinkPreviewLayerData) {
return LinkPreviewLayer(
if (layerItem is EmojiLayerData) {
return EmojiLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is DrawLayerData) {
return DrawLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is TextLayerData) {
return TextLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is LinkPreviewLayerData) {
return LinkPreviewLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
}
return Container();
}),
...layers.whereType<BackgroundLayerData>().map((layerItem) {
if (layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else {
return Container();
}
return Container();
}),
],
);

View file

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

View file

@ -287,7 +287,15 @@ class _MediaViewerViewState extends State<MediaViewerView> {
var timerRequired = false;
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(
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
);

View file

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