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,14 +26,14 @@ 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(
await (delete(reactions)..where(
(t) =>
t.senderId.equals(contactId) &
t.messageId.equals(messageId) &
@ -63,13 +63,13 @@ 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(
await (delete(reactions)..where(
(t) =>
t.senderId.isNull() &
t.messageId.equals(messageId) &
@ -98,9 +98,8 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
}
Stream<Reaction?> watchLastReactions(String groupId) {
final query = (select(reactions)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.join(
final query =
(select(reactions)).join(
[
innerJoin(
messages,
@ -110,7 +109,7 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
],
)
..where(messages.groupId.equals(groupId))
// ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
..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 {
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);
})
// 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(
final layer =
await showModalBottomSheet(
context: context,
backgroundColor: Colors.black,
builder: (context) {
return const EmojiPickerBottom();
},
) as Layer?;
)
as Layer?;
if (layer == null) return;
undoLayers.clear();
removedLayers.clear();
@ -277,7 +287,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
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,7 +445,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await videoController?.pause();
if (isDisposed || !mounted) return;
final wasSend = await Navigator.push(
final wasSend =
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageView(
@ -422,7 +457,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
additionalData: getAdditionalData(),
),
),
) as bool?;
)
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,7 +33,17 @@ 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(
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,
@ -37,6 +51,29 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
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) {
if (!layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else {
return Container();
}
}),
...layers.whereType<FilterLayerData>().map((layerItem) {
return FilterLayer(
@ -71,6 +75,17 @@ class LayersViewer extends StatelessWidget {
}
return Container();
}),
...layers.whereType<BackgroundLayerData>().map((layerItem) {
if (layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else {
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 {