This commit is contained in:
otsmr 2026-01-17 19:02:54 +01:00
parent 849102dd3b
commit 8ecae72d80
15 changed files with 184 additions and 59 deletions

View file

@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.0.86
- Allows to reopen send images (if send without time limit or enabled auth)
- Added support for front camera zoom
- Several bug fixes
## 0.0.83 ## 0.0.83
- Improved view of the diagnostic log - Improved view of the diagnostic log

View file

@ -508,6 +508,12 @@ abstract class AppLocalizations {
/// **'Unpin'** /// **'Unpin'**
String get contextMenuUnpin; String get contextMenuUnpin;
/// No description provided for @contextMenuViewAgain.
///
/// In en, this message translates to:
/// **'View again'**
String get contextMenuViewAgain;
/// No description provided for @mediaViewerAuthReason. /// No description provided for @mediaViewerAuthReason.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2943,6 +2949,12 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Currently, group size is limited to {size} people!'** /// **'Currently, group size is limited to {size} people!'**
String groupSizeLimitError(Object size); String groupSizeLimitError(Object size);
/// No description provided for @authRequestReopenImage.
///
/// In en, this message translates to:
/// **'You must authenticate to reopen the image.'**
String get authRequestReopenImage;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -234,6 +234,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contextMenuUnpin => 'Lösen'; String get contextMenuUnpin => 'Lösen';
@override
String get contextMenuViewAgain => 'Nochmal anschauen';
@override @override
String get mediaViewerAuthReason => String get mediaViewerAuthReason =>
'Bitte authentifiziere dich, um diesen twonly zu sehen!'; 'Bitte authentifiziere dich, um diesen twonly zu sehen!';
@ -1642,4 +1645,8 @@ class AppLocalizationsDe extends AppLocalizations {
String groupSizeLimitError(Object size) { String groupSizeLimitError(Object size) {
return 'Derzeit ist die Gruppengröße auf $size Personen begrenzt!'; return 'Derzeit ist die Gruppengröße auf $size Personen begrenzt!';
} }
@override
String get authRequestReopenImage =>
'Um das Bild erneut zu öffnen, musst du dich authentifizieren.';
} }

View file

@ -232,6 +232,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contextMenuUnpin => 'Unpin'; String get contextMenuUnpin => 'Unpin';
@override
String get contextMenuViewAgain => 'View again';
@override @override
String get mediaViewerAuthReason => 'Please authenticate to see this twonly!'; String get mediaViewerAuthReason => 'Please authenticate to see this twonly!';
@ -1630,4 +1633,8 @@ class AppLocalizationsEn extends AppLocalizations {
String groupSizeLimitError(Object size) { String groupSizeLimitError(Object size) {
return 'Currently, group size is limited to $size people!'; return 'Currently, group size is limited to $size people!';
} }
@override
String get authRequestReopenImage =>
'You must authenticate to reopen the image.';
} }

View file

@ -232,6 +232,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get contextMenuUnpin => 'Unpin'; String get contextMenuUnpin => 'Unpin';
@override
String get contextMenuViewAgain => 'View again';
@override @override
String get mediaViewerAuthReason => 'Please authenticate to see this twonly!'; String get mediaViewerAuthReason => 'Please authenticate to see this twonly!';
@ -1630,4 +1633,8 @@ class AppLocalizationsSv extends AppLocalizations {
String groupSizeLimitError(Object size) { String groupSizeLimitError(Object size) {
return 'Currently, group size is limited to $size people!'; return 'Currently, group size is limited to $size people!';
} }
@override
String get authRequestReopenImage =>
'You must authenticate to reopen the image.';
} }

@ -1 +1 @@
Subproject commit c1dc14a53ff2854389a50f4aaeb26946ff925367 Subproject commit 20f3c2f0a49e4c9be452ecbc84d98054c92974e1

View file

@ -59,6 +59,7 @@ class MediaFileService {
} else if (service.mediaFile.requiresAuthentication || } else if (service.mediaFile.requiresAuthentication ||
service.mediaFile.displayLimitInMilliseconds != null) { service.mediaFile.displayLimitInMilliseconds != null) {
// Message was opened by all persons, and they can not reopen the image. // Message was opened by all persons, and they can not reopen the image.
// This branch will prevent to reach the next if condition, with would otherwise store the image for two days
// delete = true; // do not overwrite a previous delete = false // delete = true; // do not overwrite a previous delete = false
// this is just to make it easier to understand :) // this is just to make it easier to understand :)
} else if (message.openedAt! } else if (message.openedAt!

View file

@ -436,7 +436,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
CameraLensDirection.front; CameraLensDirection.front;
Future<void> onPanUpdate(dynamic details) async { Future<void> onPanUpdate(dynamic details) async {
if (isFront || details == null) { if (details == null) {
return; return;
} }
if (mc.cameraController == null || if (mc.cameraController == null ||
@ -603,9 +603,6 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
bottomNavigation: Container(), bottomNavigation: Container(),
child: GestureDetector( child: GestureDetector(
onPanStart: (details) async { onPanStart: (details) async {
if (isFront) {
return;
}
setState(() { setState(() {
_basePanY = details.localPosition.dy; _basePanY = details.localPosition.dy;
_baseScaleFactor = mc.selectedCameraDetails.scaleFactor; _baseScaleFactor = mc.selectedCameraDetails.scaleFactor;
@ -721,12 +718,11 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
children: [ children: [
if (mc.cameraController!.value.isInitialized && if (mc.cameraController!.value.isInitialized &&
mc.selectedCameraDetails.isZoomAble && mc.selectedCameraDetails.isZoomAble &&
!isFront &&
!_isVideoRecording) !_isVideoRecording)
SizedBox( SizedBox(
width: 120, width: 120,
child: CameraZoomButtons( child: CameraZoomButtons(
key: widget.key, key: mc.zoomButtonKey,
scaleFactor: mc.selectedCameraDetails.scaleFactor, scaleFactor: mc.selectedCameraDetails.scaleFactor,
updateScaleFactor: updateScaleFactor, updateScaleFactor: updateScaleFactor,
selectCamera: mc.selectCamera, selectCamera: mc.selectCamera,

View file

@ -44,6 +44,7 @@ class MainCameraController {
Map<int, ScannedVerifiedContact> contactsVerified = {}; Map<int, ScannedVerifiedContact> contactsVerified = {};
Map<int, ScannedNewProfile> scannedNewProfiles = {}; Map<int, ScannedNewProfile> scannedNewProfiles = {};
String? scannedUrl; String? scannedUrl;
GlobalKey zoomButtonKey = GlobalKey();
Future<void> closeCamera() async { Future<void> closeCamera() async {
contactsVerified = {}; contactsVerified = {};
@ -76,6 +77,7 @@ class MainCameraController {
CameraLensDirection.back) { CameraLensDirection.back) {
await cameraController?.startImageStream(_processCameraImage); await cameraController?.startImageStream(_processCameraImage);
} }
zoomButtonKey = GlobalKey();
setState(); setState();
return cameraController; return cameraController;
} }
@ -89,10 +91,11 @@ class MainCameraController {
try { try {
await cameraController!.stopImageStream(); await cameraController!.stopImageStream();
} catch (e) { } catch (e) {
Log.warn(e); // Log.warn(e);
} }
await cameraController!.dispose(); final tmp = cameraController;
cameraController = null; cameraController = null;
await tmp!.dispose();
await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false); await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false);
} }

View file

@ -1,19 +1,23 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
class SaveToGalleryButton extends StatefulWidget { class SaveToGalleryButton extends StatefulWidget {
const SaveToGalleryButton({ const SaveToGalleryButton({
required this.storeImageAsOriginal,
required this.isLoading, required this.isLoading,
required this.displayButtonLabel, required this.displayButtonLabel,
required this.mediaService, required this.mediaService,
this.storeImageAsOriginal,
super.key, super.key,
}); });
final Future<Uint8List?> Function() storeImageAsOriginal; final Future<Uint8List?> Function()? storeImageAsOriginal;
final bool displayButtonLabel; final bool displayButtonLabel;
final MediaFileService mediaService; final MediaFileService mediaService;
final bool isLoading; final bool isLoading;
@ -44,8 +48,32 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
_imageSaving = true; _imageSaving = true;
}); });
await widget.storeImageAsOriginal(); if (widget.storeImageAsOriginal != null) {
await widget.mediaService.storeMediaFile(); await widget.storeImageAsOriginal!();
}
final newMediaFile = await twonlyDB.mediaFilesDao.insertMedia(
MediaFilesCompanion(
type: Value(widget.mediaService.mediaFile.type),
createdAt: Value(clock.now()),
stored: const Value(true),
),
);
if (newMediaFile != null) {
final newService = MediaFileService(newMediaFile);
if (widget.mediaService.tempPath.existsSync()) {
widget.mediaService.tempPath.copySync(
newService.tempPath.path,
);
} else if (widget.mediaService.originalPath.existsSync()) {
widget.mediaService.originalPath.copySync(
newService.originalPath.path,
);
}
await newService.storeMediaFile();
}
setState(() { setState(() {
_imageSaved = true; _imageSaved = true;

View file

@ -62,8 +62,16 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
_wideCameraIndex = index; _wideCameraIndex = index;
} }
if (!showWideAngleZoom && Platform.isIOS && _wideCameraIndex != null) { final isFront = widget.controller.description.lensDirection ==
CameraLensDirection.front;
if (!showWideAngleZoom &&
Platform.isIOS &&
_wideCameraIndex != null &&
!isFront) {
showWideAngleZoomIOS = true; showWideAngleZoomIOS = true;
} else {
showWideAngleZoomIOS = false;
} }
if (_isDisposed) return; if (_isDisposed) return;
setState(() {}); setState(() {});

View file

@ -2,7 +2,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -487,20 +486,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
} }
} }
// In case the image was already stored, then rename the stored image.
if (mediaService.storedPath.existsSync()) {
final mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
MediaFilesCompanion(
type: Value(mediaService.mediaFile.type),
createdAt: Value(clock.now()),
stored: const Value(true),
),
);
if (mediaFile != null) {
mediaService.storedPath
.renameSync(MediaFileService(mediaFile).storedPath.path);
}
}
return bytes; return bytes;
} }

View file

@ -185,6 +185,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
group: widget.group, group: widget.group,
onResponseTriggered: widget.onResponseTriggered!, onResponseTriggered: widget.onResponseTriggered!,
galleryItems: widget.galleryItems, galleryItems: widget.galleryItems,
mediaFileService: mediaService,
child: Container( child: Container(
child: child, child: child,
), ),

View file

@ -12,12 +12,14 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart' import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
as pb; as pb;
import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart'; import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/chats/message_info.view.dart'; import 'package:twonly/src/views/chats/message_info.view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/context_menu.component.dart'; import 'package:twonly/src/views/components/context_menu.component.dart';
import 'package:twonly/src/views/memories/memories_photo_slider.view.dart';
class MessageContextMenu extends StatelessWidget { class MessageContextMenu extends StatelessWidget {
const MessageContextMenu({ const MessageContextMenu({
@ -26,16 +28,55 @@ class MessageContextMenu extends StatelessWidget {
required this.child, required this.child,
required this.onResponseTriggered, required this.onResponseTriggered,
required this.galleryItems, required this.galleryItems,
required this.mediaFileService,
super.key, super.key,
}); });
final Group group; final Group group;
final Widget child; final Widget child;
final Message message; final Message message;
final List<MemoryItem> galleryItems; final List<MemoryItem> galleryItems;
final MediaFileService? mediaFileService;
final VoidCallback onResponseTriggered; final VoidCallback onResponseTriggered;
Future<void> reopenMediaFile(BuildContext context) async {
final isAuth = await authenticateUser(
context.lang.authRequestReopenImage,
force: false,
);
if (isAuth && context.mounted && mediaFileService != null) {
final galleryItems = [
MemoryItem(mediaService: mediaFileService!, messages: []),
];
await Navigator.push(
context,
PageRouteBuilder(
opaque: false,
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView(
galleryItems: galleryItems,
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var canBeOpenedAgain = false;
// in case this is a media send from this user...
if (mediaFileService != null && message.senderId == null) {
// and the media was send with unlimited display limit time and without auth required...
if (!mediaFileService!.mediaFile.requiresAuthentication &&
mediaFileService!.mediaFile.displayLimitInMilliseconds == null) {
// and the temp media file still exists
if (mediaFileService!.tempPath.existsSync()) {
// the media file can be opened again...
canBeOpenedAgain = true;
}
}
}
return ContextMenu( return ContextMenu(
items: [ items: [
if (!message.isDeletedFromSender) if (!message.isDeletedFromSender)
@ -70,6 +111,12 @@ class MessageContextMenu extends StatelessWidget {
}, },
icon: FontAwesomeIcons.faceLaugh, icon: FontAwesomeIcons.faceLaugh,
), ),
if (canBeOpenedAgain)
ContextMenuItem(
title: context.lang.contextMenuViewAgain,
onTap: () => reopenMediaFile(context),
icon: FontAwesomeIcons.clockRotateLeft,
),
if (!message.isDeletedFromSender) if (!message.isDeletedFromSender)
ContextMenuItem( ContextMenuItem(
title: context.lang.reply, title: context.lang.reply,

View file

@ -8,6 +8,7 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart'; import 'package:twonly/src/services/api/mediafiles/upload.service.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/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_preview_components/save_to_gallery.dart';
import 'package:twonly/src/views/camera/share_image_editor_view.dart'; import 'package:twonly/src/views/camera/share_image_editor_view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart';
@ -92,8 +93,36 @@ class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
} }
} }
Future<void> shareMediaFile() async {
final orgMediaService = widget.galleryItems[currentIndex].mediaService;
final newMediaService = await initializeMediaUpload(
orgMediaService.mediaFile.type,
gUser.defaultShowTime,
);
if (newMediaService == null) {
Log.error('Could not create new mediaFIle');
return;
}
orgMediaService.storedPath.copySync(newMediaService.originalPath.path);
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageEditorView(
mediaFileService: newMediaService,
sharedFromGallery: true,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final orgMediaService = widget.galleryItems[currentIndex].mediaService;
return Dismissible( return Dismissible(
key: key, key: key,
direction: DismissDirection.vertical, direction: DismissDirection.vertical,
@ -117,36 +146,18 @@ class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (!orgMediaService.storedPath.existsSync())
Padding(
padding: const EdgeInsets.only(right: 12),
child: SaveToGalleryButton(
isLoading: false,
displayButtonLabel: true,
mediaService: orgMediaService,
),
),
FilledButton.icon( FilledButton.icon(
icon: const FaIcon(FontAwesomeIcons.solidPaperPlane), icon: const FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async { onPressed: shareMediaFile,
final orgMediaService =
widget.galleryItems[currentIndex].mediaService;
final newMediaService = await initializeMediaUpload(
orgMediaService.mediaFile.type,
gUser.defaultShowTime,
);
if (newMediaService == null) {
Log.error('Could not create new mediaFIle');
return;
}
orgMediaService.storedPath
.copySync(newMediaService.originalPath.path);
if (!context.mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageEditorView(
mediaFileService: newMediaService,
sharedFromGallery: true,
),
),
);
},
style: ButtonStyle( style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>( padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric( const EdgeInsets.symmetric(
@ -217,10 +228,16 @@ class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) { PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
final item = widget.galleryItems[index]; final item = widget.galleryItems[index];
var filePath = item.mediaService.storedPath;
if (!filePath.existsSync()) {
filePath = item.mediaService.tempPath;
}
return item.mediaService.mediaFile.type == MediaType.video return item.mediaService.mediaFile.type == MediaType.video
? PhotoViewGalleryPageOptions.customChild( ? PhotoViewGalleryPageOptions.customChild(
child: VideoPlayerWrapper( child: VideoPlayerWrapper(
videoPath: item.mediaService.storedPath, videoPath: filePath,
), ),
// childSize: const Size(300, 300), // childSize: const Size(300, 300),
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
@ -231,7 +248,7 @@ class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
), ),
) )
: PhotoViewGalleryPageOptions( : PhotoViewGalleryPageOptions(
imageProvider: FileImage(item.mediaService.storedPath), imageProvider: FileImage(filePath),
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1, maxScale: PhotoViewComputedScale.covered * 4.1,