Merge pull request #421 from twonlyapp/dev

Dev
This commit is contained in:
Tobi 2026-06-20 01:51:32 +02:00 committed by GitHub
commit 6a106a7fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 809 additions and 593 deletions

View file

@ -1,5 +1,14 @@
# Changelog # Changelog
## 0.3.3
- Fix: Multiple UI issues
- Fix: Camera initialization issue
## 0.3.2
- Fix: Multiple smaller performance and UI issues
## 0.3.1 ## 0.3.1
- New: Promotion of sharing contacts when contact is new to twonly - New: Promotion of sharing contacts when contact is new to twonly

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
@ -32,7 +33,7 @@ import 'package:twonly/src/utils/startup_guard.dart';
final _initMutex = Mutex(); final _initMutex = Mutex();
/// This function is used to initialized the absolute minimum so it /// This function is used to initialize the absolute minimum so it
/// can also be used by the backend without the UI was loaded. /// can also be used by the backend without the UI was loaded.
Future<bool> twonlyMinimumInitialization() async { Future<bool> twonlyMinimumInitialization() async {
Log.info('twonlyMinimumInitialization: called'); Log.info('twonlyMinimumInitialization: called');
@ -68,12 +69,21 @@ Future<bool> twonlyMinimumInitialization() async {
void main() async { void main() async {
final binding = SentryWidgetsFlutterBinding.ensureInitialized(); final binding = SentryWidgetsFlutterBinding.ensureInitialized();
await AppEnvironment.init(); await AppEnvironment.init();
// Preload available cameras in the background to speed up camera tab startup
unawaited(
availableCameras().then((cameras) {
AppEnvironment.cameras = cameras;
}),
);
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
unawaited(StartupGuard.markAppStartup()); unawaited(StartupGuard.markAppStartup());
var storageError = await twonlyMinimumInitialization(); var storageError = await twonlyMinimumInitialization();
await FcmNotificationService.initStartup(); await FcmNotificationService.initStartup();
await setupPushNotification();
var userExists = false; var userExists = false;
@ -163,7 +173,6 @@ Future<void> postStartupTasks() async {
unawaited(MediaFileService.purgeTempFolder()); unawaited(MediaFileService.purgeTempFolder());
// 2. Service initializations // 2. Service initializations
unawaited(setupPushNotification());
unawaited(finishStartedPreprocessing()); unawaited(finishStartedPreprocessing());
unawaited(createPushAvatars()); unawaited(createPushAvatars());

View file

@ -314,11 +314,15 @@ class MediaFileService {
await tempPath.copy(storedPath.path); await tempPath.copy(storedPath.path);
if (userService.currentUser.storeMediaFilesInGallery) { if (userService.currentUser.storeMediaFilesInGallery) {
if (mediaFile.type == MediaType.video) { if (mediaFile.type == MediaType.video) {
await saveVideoToGallery(storedPath.path); await saveVideoToGallery(
storedPath.path,
name: mediaFile.mediaId,
);
} else { } else {
await saveImageToGallery( await saveImageToGallery(
storedPath.readAsBytesSync(), storedPath.readAsBytesSync(),
createdAt: mediaFile.createdAt, createdAt: mediaFile.createdAt,
name: mediaFile.mediaId,
); );
} }
} }

View file

@ -159,6 +159,7 @@ Future<void> showLocalPushNotification(
ticker: 'You got a new message.', ticker: 'You got a new message.',
largeIcon: styleInformation, largeIcon: styleInformation,
icon: 'ic_launcher_foreground', icon: 'ic_launcher_foreground',
groupKey: 'com.twonly.messages',
); );
const darwinNotificationDetails = DarwinNotificationDetails(); const darwinNotificationDetails = DarwinNotificationDetails();
@ -190,43 +191,29 @@ Future<void> showLocalPushNotification(
notificationDetails, notificationDetails,
payload: payload, payload: payload,
); );
}
Future<void> showLocalPushNotificationWithoutUserId( if (Platform.isAndroid) {
PushNotification pushNotification, final summaryAndroidDetails = AndroidNotificationDetails(
) async { '0',
final lang = getLocalizations(); lang.notificationCategoryMessageTitle,
channelDescription: lang.notificationCategoryMessageDesc,
var title = lang.notificationTitleUnknown; importance: Importance.max,
var body = lang.notificationBodyUnknown; priority: Priority.max,
groupKey: 'com.twonly.messages',
if (pushNotification.kind == PushKind.CONTACT_REQUEST) { setAsGroupSummary: true,
title = lang.you; icon: 'ic_launcher_foreground',
body = lang.notificationContactRequestUnknownUser; );
final summaryNotificationDetails = NotificationDetails(
android: summaryAndroidDetails,
);
await flutterLocalNotificationsPlugin.show(
0,
lang.notificationCategoryMessageTitle,
'',
summaryNotificationDetails,
payload: Routes.chats,
);
} }
final androidNotificationDetails = AndroidNotificationDetails(
'0',
lang.notificationCategoryMessageTitle,
channelDescription: lang.notificationCategoryMessageDesc,
importance: Importance.max,
priority: Priority.max,
ticker: 'You got a new message.',
);
const darwinNotificationDetails = DarwinNotificationDetails();
final notificationDetails = NotificationDetails(
android: androidNotificationDetails,
iOS: darwinNotificationDetails,
);
await flutterLocalNotificationsPlugin.show(
2,
title,
body,
notificationDetails,
payload: pushNotification.kind.name,
);
} }
Future<String?> getAvatarIcon(int contactId) async { Future<String?> getAvatarIcon(int contactId) async {

View file

@ -11,6 +11,7 @@ import 'package:gal/gal.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:path/path.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
@ -36,6 +37,7 @@ extension ShortCutsExtension on BuildContext {
Future<String?> saveImageToGallery( Future<String?> saveImageToGallery(
Uint8List imageBytes, { Uint8List imageBytes, {
DateTime? createdAt, DateTime? createdAt,
String? name,
}) async { }) async {
var bytesToProcess = imageBytes; var bytesToProcess = imageBytes;
@ -75,7 +77,7 @@ Future<String?> saveImageToGallery(
await Gal.requestAccess(toAlbum: true); await Gal.requestAccess(toAlbum: true);
} }
try { try {
await Gal.putImageBytes(jpgImages, album: 'twonly'); await Gal.putImageBytes(jpgImages, album: 'twonly', name: name ?? 'image');
return null; return null;
} on GalException catch (e) { } on GalException catch (e) {
Log.error(e); Log.error(e);
@ -83,17 +85,45 @@ Future<String?> saveImageToGallery(
} }
} }
Future<String?> saveVideoToGallery(String videoPath) async { Future<String?> saveVideoToGallery(
String videoPath, {
String? name,
}) async {
final hasAccess = await Gal.hasAccess(toAlbum: true); final hasAccess = await Gal.hasAccess(toAlbum: true);
if (!hasAccess) { if (!hasAccess) {
await Gal.requestAccess(toAlbum: true); await Gal.requestAccess(toAlbum: true);
} }
var pathToSave = videoPath;
File? tempFile;
try { try {
await Gal.putVideo(videoPath, album: 'twonly'); if (name != null) {
final file = File(videoPath);
final extension = file.path.split('.').last;
final tempDir = Directory.systemTemp;
tempFile = File(join(tempDir.path, '$name.$extension'));
if (tempFile.existsSync()) {
try {
tempFile.deleteSync();
} catch (_) {}
}
file.copySync(tempFile.path);
pathToSave = tempFile.path;
}
await Gal.putVideo(pathToSave, album: 'twonly');
return null; return null;
} on GalException catch (e) { } on GalException catch (e) {
Log.error(e); Log.error(e);
return e.type.message; return e.type.message;
} finally {
if (tempFile != null && tempFile.existsSync()) {
try {
tempFile.deleteSync();
} catch (e) {
Log.error('Failed to delete temp video file: $e');
}
}
} }
} }

View file

@ -10,6 +10,7 @@ enum MyButtonVariant {
primaryMiddle, primaryMiddle,
primaryDense, primaryDense,
secondaryDense, secondaryDense,
secondaryMiddle,
error, error,
} }
@ -212,6 +213,25 @@ class _MyButtonState extends State<MyButton>
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
); );
case MyButtonVariant.secondaryMiddle:
buttonStyle = FilledButton.styleFrom(
backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200],
foregroundColor: isDark ? Colors.white : Colors.black87,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(
horizontal: 24,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
case MyButtonVariant.error: case MyButtonVariant.error:
buttonStyle = FilledButton.styleFrom( buttonStyle = FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer, backgroundColor: Theme.of(context).colorScheme.errorContainer,

View file

@ -108,6 +108,7 @@ class _CameraPreviewControllerViewState
); );
} else { } else {
return PermissionHandlerView( return PermissionHandlerView(
triggerPermissionRequest: widget.isVisible,
onSuccess: () { onSuccess: () {
setState(() { setState(() {
AppState.hasCameraPermissions = true; AppState.hasCameraPermissions = true;

View file

@ -134,14 +134,25 @@ class MainCameraController {
} }
Future<void> selectCamera(int sCameraId, bool init) async { Future<void> selectCamera(int sCameraId, bool init) async {
if (initCameraStarted) return;
initCameraStarted = true; initCameraStarted = true;
final sessionId = ++_cameraSessionId; final sessionId = ++_cameraSessionId;
await _pendingDisposal;
if (sessionId != _cameraSessionId) return;
if (AppEnvironment.cameras.isEmpty) { // Start checking microphone permission concurrently
AppEnvironment.cameras = await availableCameras(); final micPermissionFuture = Permission.microphone.isGranted;
try {
await _pendingDisposal;
if (sessionId != _cameraSessionId) return; if (sessionId != _cameraSessionId) return;
if (AppEnvironment.cameras.isEmpty) {
AppEnvironment.cameras = await availableCameras();
if (sessionId != _cameraSessionId) return;
}
} catch (e) {
Log.error('Error querying available cameras: $e');
initCameraStarted = false;
return;
} }
var cameraId = sCameraId; var cameraId = sCameraId;
@ -149,6 +160,7 @@ class MainCameraController {
Log.warn( Log.warn(
'Trying to select a non existing camera $cameraId >= ${AppEnvironment.cameras.length}', 'Trying to select a non existing camera $cameraId >= ${AppEnvironment.cameras.length}',
); );
initCameraStarted = false;
return; return;
} }
@ -159,12 +171,21 @@ class MainCameraController {
break; break;
} }
} }
if (cameraId >= AppEnvironment.cameras.length) {
cameraId = sCameraId;
}
} }
selectedCameraDetails.isZoomAble = false; selectedCameraDetails.isZoomAble = false;
if (cameraController == null) { if (cameraController == null || !cameraController!.value.isInitialized) {
final hasMic = await Permission.microphone.isGranted; final controllerToDispose = cameraController;
cameraController = null;
if (controllerToDispose != null) {
unawaited(controllerToDispose.dispose());
}
final hasMic = await micPermissionFuture;
if (sessionId != _cameraSessionId) return; if (sessionId != _cameraSessionId) return;
cameraController = CameraController( cameraController = CameraController(
@ -178,58 +199,55 @@ class MainCameraController {
try { try {
_initializeFuture = cameraController?.initialize(); _initializeFuture = cameraController?.initialize();
await _initializeFuture; await _initializeFuture;
if (cameraController == null) return; await cameraController!.startImageStream(_processCameraImage);
await cameraController?.startImageStream(_processCameraImage); await cameraController!.setZoomLevel(selectedCameraDetails.scaleFactor);
if (cameraController == null) return;
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
if (cameraController == null) return;
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) { if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
await cameraController?.setVideoStabilizationMode( await cameraController!.setVideoStabilizationMode(
VideoStabilizationMode.level1, VideoStabilizationMode.level1,
); );
} }
} catch (e) { } catch (e) {
Log.error('Error initializing camera: $e');
final controllerToDispose = cameraController;
cameraController = null; cameraController = null;
if (controllerToDispose != null) {
unawaited(controllerToDispose.dispose());
}
initCameraStarted = false;
return; return;
} }
} else { } else {
try { try {
if (!isVideoRecording) { if (!isVideoRecording) {
await cameraController?.stopImageStream(); await cameraController!.stopImageStream();
} }
} catch (e) { selectedCameraDetails.scaleFactor = 1;
Log.info(e);
}
if (cameraController == null) return;
selectedCameraDetails.scaleFactor = 1;
await cameraController?.setZoomLevel(1); await cameraController!.setZoomLevel(1);
if (cameraController == null) return; await cameraController!.setDescription(
await cameraController?.setDescription(AppEnvironment.cameras[cameraId]); AppEnvironment.cameras[cameraId],
if (cameraController == null) return; );
try {
if (!isVideoRecording) { if (!isVideoRecording) {
await cameraController?.startImageStream(_processCameraImage); await cameraController!.startImageStream(_processCameraImage);
} }
} catch (e) { } catch (e) {
Log.info(e); Log.error('Error switching camera description: $e');
initCameraStarted = false;
return;
} }
} }
try { try {
if (cameraController == null) return; await cameraController!.lockCaptureOrientation(
await cameraController?.lockCaptureOrientation(
DeviceOrientation.portraitUp, DeviceOrientation.portraitUp,
); );
if (cameraController == null) return; await cameraController!.setFlashMode(
await cameraController?.setFlashMode(
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
); );
if (cameraController == null) return; selectedCameraDetails.maxAvailableZoom = await cameraController!
selectedCameraDetails.maxAvailableZoom = .getMaxZoomLevel();
await cameraController?.getMaxZoomLevel() ?? 1; selectedCameraDetails.minAvailableZoom = await cameraController!
selectedCameraDetails.minAvailableZoom = .getMinZoomLevel();
await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails selectedCameraDetails
..isZoomAble = ..isZoomAble =
selectedCameraDetails.maxAvailableZoom != selectedCameraDetails.maxAvailableZoom !=
@ -242,10 +260,16 @@ class MainCameraController {
isSelectingFaceFilters = false; isSelectingFaceFilters = false;
setFilter(FaceFilterType.none); setFilter(FaceFilterType.none);
zoomButtonKey = GlobalKey(); zoomButtonKey = GlobalKey();
initCameraStarted = false;
setState?.call(); setState?.call();
} catch (e) { } catch (e) {
Log.error(e); Log.error('Error post-initializing camera: $e');
final controllerToDispose = cameraController;
cameraController = null; cameraController = null;
if (controllerToDispose != null) {
unawaited(controllerToDispose.dispose());
}
initCameraStarted = false;
return; return;
} }
} }
@ -312,7 +336,6 @@ class MainCameraController {
} }
final inputImage = _inputImageFromCameraImage(image); final inputImage = _inputImageFromCameraImage(image);
if (inputImage == null) return; if (inputImage == null) return;
_processBarcode(inputImage);
// check if front camera is selected // check if front camera is selected
if (cameraController?.description.lensDirection == if (cameraController?.description.lensDirection ==
CameraLensDirection.front) { CameraLensDirection.front) {
@ -462,7 +485,10 @@ class MainCameraController {
scannedUrl = link; scannedUrl = link;
if (sharedLinkForPreview == null) { if (sharedLinkForPreview == null) {
timeSharedLinkWasSetWithQr = clock.now(); timeSharedLinkWasSetWithQr = clock.now();
setSharedLinkForPreview(Uri.parse(scannedUrl!), generatePreview: false); setSharedLinkForPreview(
Uri.parse(scannedUrl!),
generatePreview: false,
);
} }
} }
} }

View file

@ -2,12 +2,18 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
class PermissionHandlerView extends StatefulWidget { class PermissionHandlerView extends StatefulWidget {
const PermissionHandlerView({required this.onSuccess, super.key}); const PermissionHandlerView({
required this.onSuccess,
this.triggerPermissionRequest = true,
super.key,
});
final Function onSuccess; final Function onSuccess;
final bool triggerPermissionRequest;
@override @override
PermissionHandlerViewState createState() => PermissionHandlerViewState(); PermissionHandlerViewState createState() => PermissionHandlerViewState();
@ -17,10 +23,6 @@ Future<bool> checkPermissions() async {
if (!await Permission.camera.isGranted) { if (!await Permission.camera.isGranted) {
return false; return false;
} }
if (!await Permission.microphone.isGranted) {
// microphone is only needed when
return true;
}
return true; return true;
} }
@ -36,6 +38,32 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
_timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { _timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
await _checkAndTriggerSuccess(); await _checkAndTriggerSuccess();
}); });
if (widget.triggerPermissionRequest) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _requestPermissions();
});
}
}
@override
void didUpdateWidget(PermissionHandlerView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.triggerPermissionRequest && !oldWidget.triggerPermissionRequest) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _requestPermissions();
});
}
}
Future<void> _requestPermissions() async {
try {
await permissionServices();
if (await checkPermissions()) {
_handleSuccess();
}
} catch (e) {
Log.error(e);
}
} }
@override @override
@ -54,21 +82,32 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
Future<void> _checkAndTriggerSuccess() async { Future<void> _checkAndTriggerSuccess() async {
if (_isSuccessTriggered) return; if (_isSuccessTriggered) return;
final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) {
return;
}
try { try {
if (await checkPermissions()) { if (await checkPermissions()) {
_isSuccessTriggered = true; _handleSuccess();
_timer?.cancel();
// ignore: avoid_dynamic_calls
widget.onSuccess();
} }
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
} }
} }
void _handleSuccess() {
if (_isSuccessTriggered) return;
_isSuccessTriggered = true;
_timer?.cancel();
unawaited(UserService.update((u) => u.requestedAudioPermission = true));
// ignore: avoid_dynamic_calls
widget.onSuccess();
}
Future<Map<Permission, PermissionStatus>> permissionServices() async { Future<Map<Permission, PermissionStatus>> permissionServices() async {
final statuses = await [ final statuses = await [
Permission.camera, Permission.camera,
Permission.microphone,
Permission.notification, Permission.notification,
].request(); ].request();
@ -100,8 +139,7 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
try { try {
await permissionServices(); await permissionServices();
if (await checkPermissions()) { if (await checkPermissions()) {
// ignore: avoid_dynamic_calls _handleSuccess();
widget.onSuccess();
} }
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);

View file

@ -36,7 +36,7 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEnabled = !widget.isLoading && !_imageSaving; final isEnabled = !widget.isLoading && !_imageSaving;
return MyButton( return MyButton(
variant: MyButtonVariant.secondaryDense, variant: MyButtonVariant.secondaryMiddle,
onPressed: isEnabled onPressed: isEnabled
? () async { ? () async {
setState(() { setState(() {

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.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';
@ -19,7 +20,11 @@ class QrCodeScannerViewState extends State<QrCodeScannerView> {
_mainCameraController.setState = () { _mainCameraController.setState = () {
if (mounted) setState(() {}); if (mounted) setState(() {});
}; };
unawaited(_mainCameraController.selectCamera(0, true)); Permission.camera.isGranted.then((hasPermission) {
if (hasPermission && mounted) {
unawaited(_mainCameraController.selectCamera(0, true));
}
});
} }
@override @override
@ -44,10 +49,12 @@ class QrCodeScannerViewState extends State<QrCodeScannerView> {
onTapDown: _mainCameraController.onTapDown, onTapDown: _mainCameraController.onTapDown,
), ),
), ),
CameraPreviewControllerView( Positioned.fill(
mainController: _mainCameraController, child: CameraPreviewControllerView(
hideControllers: true, mainController: _mainCameraController,
isVisible: true, hideControllers: true,
isVisible: true,
),
), ),
], ],
), ),

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
@ -21,7 +22,11 @@ class CameraSendToViewState extends State<CameraSendToView> {
_mainCameraController.setState = () { _mainCameraController.setState = () {
if (mounted) setState(() {}); if (mounted) setState(() {});
}; };
unawaited(_mainCameraController.selectCamera(0, true)); Permission.camera.isGranted.then((hasPermission) {
if (hasPermission && mounted) {
unawaited(_mainCameraController.selectCamera(0, true));
}
});
} }
@override @override
@ -46,10 +51,12 @@ class CameraSendToViewState extends State<CameraSendToView> {
onTapDown: _mainCameraController.onTapDown, onTapDown: _mainCameraController.onTapDown,
), ),
), ),
CameraPreviewControllerView( Positioned.fill(
mainController: _mainCameraController, child: CameraPreviewControllerView(
sendToGroup: widget.sendToGroup, mainController: _mainCameraController,
isVisible: true, sendToGroup: widget.sendToGroup,
isVisible: true,
),
), ),
], ],
), ),

View file

@ -81,7 +81,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
layers.add(FilterLayerData(key: GlobalKey())); layers.add(FilterLayerData(key: GlobalKey()));
} }
if (widget.previewLink != null && widget.previewLink!.shouldGeneratePreview) { if (widget.previewLink != null &&
widget.previewLink!.shouldGeneratePreview) {
layers.add( layers.add(
LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url), LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url),
); );
@ -724,7 +725,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (widget.sendToGroup != null) const SizedBox(width: 10), if (widget.sendToGroup != null) const SizedBox(width: 10),
if (widget.sendToGroup != null) if (widget.sendToGroup != null)
MyButton( MyButton(
variant: MyButtonVariant.secondaryDense, variant: MyButtonVariant.secondaryMiddle,
onPressed: pushShareImageView, onPressed: pushShareImageView,
child: const FaIcon( child: const FaIcon(
FontAwesomeIcons.userPlus, FontAwesomeIcons.userPlus,

View file

@ -1,9 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -13,7 +10,6 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.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/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -32,7 +28,7 @@ class ChatListView extends StatefulWidget {
State<ChatListView> createState() => _ChatListViewState(); State<ChatListView> createState() => _ChatListViewState();
} }
class _ChatListViewState extends State<ChatListView> { class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClientMixin<ChatListView> {
StreamSubscription<void>? _userSub; StreamSubscription<void>? _userSub;
StreamSubscription<List<Group>>? _contactsSub; StreamSubscription<List<Group>>? _contactsSub;
StreamSubscription<List<Contact>>? _contactsCountSub; StreamSubscription<List<Contact>>? _contactsCountSub;
@ -126,32 +122,11 @@ class _ChatListViewState extends State<ChatListView> {
} }
} }
}); });
WidgetsBinding.instance.addPostFrameCallback((_) async {
final changeLog = await rootBundle.loadString('CHANGELOG.md');
final changeLogHash = (await compute(
Sha256().hash,
changeLog.codeUnits,
)).bytes;
if (!userService.currentUser.hideChangeLog &&
userService.currentUser.lastChangeLogHash.toString() !=
changeLogHash.toString()) {
await UserService.update((u) {
u.lastChangeLogHash = changeLogHash;
});
if (!mounted) return;
// only show changelog to people who already have contacts
// this prevents that this is shown directly after the user registered
if (_groupsNotPinned.isNotEmpty) {
await context.push(
Routes.settingsHelpChangelog,
extra: changeLog,
);
}
}
});
} }
@override
bool get wantKeepAlive => true;
@override @override
void dispose() { void dispose() {
_contactsSub?.cancel(); _contactsSub?.cancel();
@ -165,6 +140,7 @@ class _ChatListViewState extends State<ChatListView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
final plan = context.watch<PurchasesProvider>().plan; final plan = context.watch<PurchasesProvider>().plan;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -1,15 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math';
import 'package:clock/clock.dart'; 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:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lottie/lottie.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:photo_view/photo_view.dart';
import 'package:screen_protector/screen_protector.dart'; import 'package:screen_protector/screen_protector.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -29,12 +26,14 @@ 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/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/elements/my_icon_button.element.dart'; import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.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/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
class MediaViewerView extends StatefulWidget { class MediaViewerView extends StatefulWidget {
@ -64,6 +63,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
DateTime? canBeSeenUntil; DateTime? canBeSeenUntil;
final ValueNotifier<double> progress = ValueNotifier(0); final ValueNotifier<double> progress = ValueNotifier(0);
bool showSendTextMessageInput = false; bool showSendTextMessageInput = false;
double maxBottomInset = 0;
DateTime? _lastTimeInputClosed;
final GlobalKey mediaWidgetKey = GlobalKey(); final GlobalKey mediaWidgetKey = GlobalKey();
bool imageSaved = false; bool imageSaved = false;
@ -82,9 +83,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
final HashSet<String> _alreadyOpenedMediaIds = HashSet(); final HashSet<String> _alreadyOpenedMediaIds = HashSet();
bool _isTransitioning = false; bool _isTransitioning = false;
bool _isZoomed = false;
late PageController _verticalPager;
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
@override @override
void initState() { void initState() {
@ -95,12 +93,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
allMediaFiles = [widget.initialMessage!]; allMediaFiles = [widget.initialMessage!];
} }
_verticalPager = PageController(initialPage: 1); listenForUnopenedMedia(true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
});
asyncLoadNextMedia(true);
} }
@override @override
@ -127,21 +120,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
); );
textMessageController.dispose(); textMessageController.dispose();
_verticalPager
..removeListener(_onVerticalScrollUpdated)
..dispose();
_backdropOpacityNotifier.dispose();
super.dispose(); super.dispose();
} }
void _onVerticalScrollUpdated() {
if (!_verticalPager.hasClients) return;
final page = _verticalPager.page ?? 1.0;
final linearFraction = min(1, max(0, page)).toDouble();
_backdropOpacityNotifier.value = linearFraction * linearFraction;
}
void _disposeVideoController() { void _disposeVideoController() {
final listener = _videoListener; final listener = _videoListener;
final controller = videoController; final controller = videoController;
@ -160,7 +141,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
(ModalRoute.of(context)?.isCurrent ?? false); (ModalRoute.of(context)?.isCurrent ?? false);
} }
Future<void> asyncLoadNextMedia(bool firstRun) async { Future<void> listenForUnopenedMedia(bool firstRun) async {
_subscription = twonlyDB.messagesDao _subscription = twonlyDB.messagesDao
.watchMediaNotOpened(widget.group.groupId) .watchMediaNotOpened(widget.group.groupId)
.listen((messages) async { .listen((messages) async {
@ -174,7 +155,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
if (msg.mediaId == currentMedia?.mediaFile.mediaId) { if (msg.mediaId == currentMedia?.mediaFile.mediaId) {
// The update of the current Media in case of a download is done in loadCurrentMediaFile // The update of the current Media in case of a download is done in loadAndDownloadCurrentMedia
continue; continue;
} }
@ -207,13 +188,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (mounted) setState(() {}); if (mounted) setState(() {});
if (firstRun) { if (firstRun) {
firstRun = false; firstRun = false;
await loadCurrentMediaFile(); await loadAndDownloadCurrentMedia();
} }
}); });
}); });
} }
Future<void> nextMediaOrExit() async { Future<void> advanceToNextMediaOrExit() async {
if (_isTransitioning) return; if (_isTransitioning) return;
_isTransitioning = true; _isTransitioning = true;
@ -245,17 +226,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
} }
} else { } else {
await loadCurrentMediaFile(); await loadAndDownloadCurrentMedia();
} }
} finally { } finally {
if (mounted) _isTransitioning = false; if (mounted) _isTransitioning = false;
} }
} }
Future<void> loadCurrentMediaFile({bool showTwonly = false}) async { Future<void> loadAndDownloadCurrentMedia({bool showTwonly = false}) async {
if (!mounted || !context.mounted) return; if (!mounted || !context.mounted) return;
if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) { if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) {
return nextMediaOrExit(); return advanceToNextMediaOrExit();
} }
try { try {
@ -293,7 +274,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
// Media file record no longer exists skip to next or exit rather // Media file record no longer exists skip to next or exit rather
// than leaving the screen permanently black with no content/loader. // than leaving the screen permanently black with no content/loader.
await downloadStateListener?.cancel(); await downloadStateListener?.cancel();
await nextMediaOrExit(); await advanceToNextMediaOrExit();
return; return;
} }
if (updated.downloadState != DownloadState.ready) { if (updated.downloadState != DownloadState.ready) {
@ -308,7 +289,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (mediaFile == null) { if (mediaFile == null) {
// DB record gone skip to next or exit. // DB record gone skip to next or exit.
await downloadStateListener?.cancel(); await downloadStateListener?.cancel();
await nextMediaOrExit(); await advanceToNextMediaOrExit();
return; return;
} }
await startDownloadMedia(mediaFile, true); await startDownloadMedia(mediaFile, true);
@ -319,18 +300,16 @@ class _MediaViewerViewState extends State<MediaViewerView> {
await downloadStateListener?.cancel(); await downloadStateListener?.cancel();
try { try {
await handleNextDownloadedMedia(showTwonly); await initializeAndDisplayCurrentMedia(showTwonly);
} catch (e, st) { } catch (e, st) {
Log.error('handleNextDownloadedMedia failed: $e\n$st'); Log.error('initializeAndDisplayCurrentMedia failed: $e\n$st');
await nextMediaOrExit(); await advanceToNextMediaOrExit();
} }
// start downloading all the other possible missing media files. // start downloading all the other possible missing media files.
}); });
} }
Future<void> handleNextDownloadedMedia( Future<void> initializeAndDisplayCurrentMedia(bool showTwonly) async {
bool showTwonly,
) async {
if (allMediaFiles.isEmpty) return; if (allMediaFiles.isEmpty) return;
setState(() { setState(() {
_showDownloadingLoader = false; _showDownloadingLoader = false;
@ -355,7 +334,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (!mounted) return; if (!mounted) return;
if (!isAuth) { if (!isAuth) {
await nextMediaOrExit(); await advanceToNextMediaOrExit();
if (mounted) { if (mounted) {
setState(() { setState(() {
displayTwonlyPresent = false; displayTwonlyPresent = false;
@ -372,25 +351,55 @@ class _MediaViewerViewState extends State<MediaViewerView> {
displayTwonlyPresent = false; displayTwonlyPresent = false;
}); });
if (!widget.group.isDirectChat) { await _updateSenderInfo();
final sender = await twonlyDB.contactsDao.getContactById( if (!mounted) return;
currentMessage!.senderId!,
);
if (!mounted) return; await _notifyMessageOpened(currentMediaLocal);
if (!mounted) return;
if (sender != null) { if (!currentMediaLocal.tempPath.existsSync()) {
_currentMediaSender = Log.error('Temp media file not found...');
'${getContactDisplayName(sender)} (${widget.group.groupName})'; await handleMediaError(currentMediaLocal.mediaFile);
} return advanceToNextMediaOrExit();
} }
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
unawaited(
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
);
if (currentMediaLocal.mediaFile.type == MediaType.video) {
await _setupVideoPlayer(currentMediaLocal);
} else {
_setupImageTimer(currentMediaLocal);
}
if (mounted) {
setState(() {
currentMedia = currentMediaLocal;
});
}
}
Future<void> _updateSenderInfo() async {
if (currentMessage == null || widget.group.isDirectChat) return;
final sender = await twonlyDB.contactsDao.getContactById(
currentMessage!.senderId!,
);
if (mounted && sender != null) {
_currentMediaSender =
'${getContactDisplayName(sender)} (${widget.group.groupName})';
}
}
Future<void> _notifyMessageOpened(MediaFileService mediaLocal) async {
if (currentMessage == null) return;
var markAsOpenMessageIDs = [currentMessage!.messageId]; var markAsOpenMessageIDs = [currentMessage!.messageId];
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened && if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
currentMediaLocal.mediaFile.storedFileHash != null) { mediaLocal.mediaFile.storedFileHash != null) {
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash( final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
currentMediaLocal.mediaFile.storedFileHash!, mediaLocal.mediaFile.storedFileHash!,
currentMessage!.senderId!, currentMessage!.senderId!,
); );
@ -408,106 +417,82 @@ class _MediaViewerViewState extends State<MediaViewerView> {
currentMessage!.senderId!, currentMessage!.senderId!,
markAsOpenMessageIDs, markAsOpenMessageIDs,
); );
}
if (!mounted) return; Future<void> _setupVideoPlayer(MediaFileService mediaLocal) async {
final controller = VideoPlayerController.file(
if (!currentMediaLocal.tempPath.existsSync()) { mediaLocal.tempPath,
Log.error('Temp media file not found...'); videoPlayerOptions: VideoPlayerOptions(
await handleMediaError(currentMediaLocal.mediaFile); mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null,
return nextMediaOrExit(); ),
}
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
unawaited(
apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!),
); );
var timerRequired = false; await controller.setLooping(
mediaLocal.mediaFile.displayLimitInMilliseconds == null,
);
if (currentMediaLocal.mediaFile.type == MediaType.video) { if (!mounted) {
final controller = VideoPlayerController.file( await controller.dispose();
currentMediaLocal.tempPath, return;
videoPlayerOptions: VideoPlayerOptions( }
// only mix in case the video can be played multiple times,
// otherwise stop the background music in case the video contains audio await controller
mixWithOthers: .initialize()
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, .then((_) {
if (!mounted || videoController != null) {
controller.dispose();
return;
}
void listener() {
if (!mounted) return;
final ctrl = videoController;
if (ctrl == null) return;
final duration = ctrl.value.duration.inSeconds;
if (duration > 0) {
progress.value = 1 - ctrl.value.position.inSeconds / duration;
}
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
if (ctrl.value.position == ctrl.value.duration) {
advanceToNextMediaOrExit();
}
}
}
_videoListener = listener;
videoController = controller;
controller
..addListener(listener)
..play();
})
.catchError((Object err, StackTrace st) {
Log.error('Video player initialization error', err, st);
return null;
});
}
void _setupImageTimer(MediaFileService mediaLocal) {
if (mediaLocal.mediaFile.displayLimitInMilliseconds != null) {
canBeSeenUntil = clock.now().add(
Duration(
milliseconds: mediaLocal.mediaFile.displayLimitInMilliseconds!,
), ),
); );
if (mounted) {
await controller.setLooping( startProgressTimer();
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
);
if (!mounted) {
await controller.dispose();
return;
}
await controller
.initialize()
.then((_) {
if (!mounted || videoController != null) {
controller.dispose();
return;
}
void listener() {
if (!mounted) return;
final ctrl = videoController;
if (ctrl == null) return;
final duration = ctrl.value.duration.inSeconds;
if (duration > 0) {
progress.value = 1 - ctrl.value.position.inSeconds / duration;
}
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds !=
null) {
if (ctrl.value.position == ctrl.value.duration) {
nextMediaOrExit();
}
}
}
_videoListener = listener;
videoController = controller;
controller
..addListener(listener)
..play();
})
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
.catchError(Log.error);
if (!mounted) return;
} else {
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
canBeSeenUntil = clock.now().add(
Duration(
milliseconds:
currentMediaLocal.mediaFile.displayLimitInMilliseconds!,
),
);
timerRequired = true;
}
}
if (mounted) {
setState(() {
currentMedia = currentMediaLocal;
});
if (timerRequired) {
startTimer();
} }
} }
} }
void startTimer() { void startProgressTimer() {
nextMediaTimer?.cancel(); nextMediaTimer?.cancel();
progressTimer?.cancel(); progressTimer?.cancel();
if (canBeSeenUntil != null) { if (canBeSeenUntil != null) {
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () { nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
if (context.mounted) { if (context.mounted) {
nextMediaOrExit(); advanceToNextMediaOrExit();
} }
}); });
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
@ -653,7 +638,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
); );
if (mounted && if (mounted &&
currentMedia!.mediaFile.displayLimitInMilliseconds != null) { currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
await nextMediaOrExit(); await advanceToNextMediaOrExit();
} else { } else {
await videoController?.play(); await videoController?.play();
} }
@ -677,21 +662,63 @@ class _MediaViewerViewState extends State<MediaViewerView> {
); );
} }
void onTap() { void _sendTextMessage() {
if (textMessageController.text.isNotEmpty) {
unawaited(
insertAndSendTextMessage(
widget.group.groupId,
textMessageController.text,
currentMessage!.messageId,
),
);
textMessageController.clear();
}
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
_lastTimeInputClosed = clock.now();
});
}
void onScreenTapped() {
if (_lastTimeInputClosed != null &&
clock.now().difference(_lastTimeInputClosed!) <
const Duration(milliseconds: 300)) {
return;
}
if (showSendTextMessageInput) { if (showSendTextMessageInput) {
setState(() { setState(() {
showShortReactions = false; showShortReactions = false;
showSendTextMessageInput = false; showSendTextMessageInput = false;
_lastTimeInputClosed = clock.now();
}); });
return; return;
} }
nextMediaOrExit(); advanceToNextMediaOrExit();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
if (bottomInset > maxBottomInset) {
maxBottomInset = bottomInset;
} else if (bottomInset == 0 && maxBottomInset > 0) {
maxBottomInset = 0;
if (showSendTextMessageInput) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
_lastTimeInputClosed = clock.now();
});
}
});
}
}
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -700,93 +727,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if ((currentMedia != null || videoController != null) && if ((currentMedia != null || videoController != null) &&
(canBeSeenUntil == null || progress.value >= 0)) (canBeSeenUntil == null || progress.value >= 0))
GestureDetector( GestureDetector(
onTap: onTap, onTap: onScreenTapped,
onDoubleTap: (videoController == null) ? null : onTap, onDoubleTap: (videoController == null) ? null : onScreenTapped,
child: MediaViewSizingHelper( child: MediaViewSizingHelper(
bottomNavigation: bottomNavigation(), bottomNavigation: bottomNavigation(),
requiredHeight: 55, requiredHeight: 55,
child: Stack( child: MediaContentRenderer(
children: [ currentMedia: currentMedia,
if (videoController != null) videoController: videoController,
Positioned.fill( loader: _loader(),
child: PhotoView.customChild(
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
child: VideoPlayer(
videoController!,
),
),
)
else if (currentMedia != null &&
(currentMedia!.mediaFile.type == MediaType.image ||
currentMedia!.mediaFile.type == MediaType.gif))
Positioned.fill(
child: PhotoView(
imageProvider: FileImage(
currentMedia!.tempPath,
),
loadingBuilder: (context, event) => _loader(),
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
),
),
],
), ),
), ),
), ),
if (displayTwonlyPresent) if (displayTwonlyPresent)
Positioned.fill( TwonlyPresentOverlay(
child: GestureDetector( onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
onTap: () => loadCurrentMediaFile(showTwonly: true),
child: Column(
children: [
Expanded(
child: Lottie.asset(
'assets/animations/present.lottie.lottie',
),
),
Container(
padding: const EdgeInsets.only(bottom: 200),
child: Text(
context.lang.mediaViewerTwonlyTapToOpen,
),
),
],
),
),
), ),
if (currentMedia != null && if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready) currentMedia?.mediaFile.downloadState != DownloadState.ready)
@ -836,63 +791,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
), ),
if (showSendTextMessageInput) if (showSendTextMessageInput)
Positioned( MediaViewerMessageInput(
bottom: 0, controller: textMessageController,
left: 0, onSubmitted: (value) => _sendTextMessage(),
right: 0, onSendPressed: _sendTextMessage,
child: Container(
color: context.color.surface,
padding: const EdgeInsets.only(
bottom: 10,
left: 20,
right: 20,
top: 10,
),
child: Row(
children: [
Expanded(
child: MyInput(
dense: true,
autofocus: true,
controller: textMessageController,
hintText: context.lang.chatListDetailInput,
onChanged: (value) {
setState(() {});
},
onSubmitted: (value) {
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
),
),
const SizedBox(width: 10),
MyIconButton(
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 20,
),
onPressed: () async {
if (textMessageController.text.isNotEmpty) {
unawaited(
insertAndSendTextMessage(
widget.group.groupId,
textMessageController.text,
currentMessage!.messageId,
),
);
textMessageController.clear();
}
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
),
],
),
),
), ),
if (currentMessage != null) if (currentMessage != null)
AdditionalMessageContent(currentMessage!), AdditionalMessageContent(currentMessage!),
@ -908,6 +810,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
setState(() { setState(() {
showShortReactions = false; showShortReactions = false;
showSendTextMessageInput = false; showSendTextMessageInput = false;
_lastTimeInputClosed = clock.now();
}); });
}, },
), ),

View file

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'
show MediaType;
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:video_player/video_player.dart';
class MediaContentRenderer extends StatelessWidget {
const MediaContentRenderer({
required this.currentMedia,
required this.videoController,
required this.loader,
super.key,
});
final MediaFileService? currentMedia;
final VideoPlayerController? videoController;
final Widget loader;
@override
Widget build(BuildContext context) {
return Stack(
children: [
if (videoController != null)
Positioned.fill(
child: PhotoView.customChild(
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
child: VideoPlayer(
videoController!,
),
),
)
else if (currentMedia != null &&
(currentMedia!.mediaFile.type == MediaType.image ||
currentMedia!.mediaFile.type == MediaType.gif))
Positioned.fill(
child: PhotoView(
imageProvider: FileImage(
currentMedia!.tempPath,
),
loadingBuilder: (context, event) => loader,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
),
),
],
);
}
}

View file

@ -0,0 +1,58 @@
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/elements/my_icon_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
class MediaViewerMessageInput extends StatelessWidget {
const MediaViewerMessageInput({
required this.controller,
required this.onSubmitted,
required this.onSendPressed,
super.key,
});
final TextEditingController controller;
final ValueChanged<String> onSubmitted;
final VoidCallback onSendPressed;
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: context.color.surface,
padding: const EdgeInsets.only(
bottom: 10,
left: 20,
right: 20,
top: 10,
),
child: Row(
children: [
Expanded(
child: MyInput(
dense: true,
autofocus: true,
controller: controller,
hintText: context.lang.chatListDetailInput,
onChanged: (value) {},
onSubmitted: onSubmitted,
),
),
const SizedBox(width: 10),
MyIconButton(
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 20,
),
onPressed: onSendPressed,
),
],
),
),
);
}
}

View file

@ -37,7 +37,6 @@ class ReactionButtons extends StatefulWidget {
} }
class _ReactionButtonsState extends State<ReactionButtons> { class _ReactionButtonsState extends State<ReactionButtons> {
int selectedShortReaction = -1;
final GlobalKey _keyEmojiPicker = GlobalKey(); final GlobalKey _keyEmojiPicker = GlobalKey();
bool _renderAnimations = false; bool _renderAnimations = false;
@ -74,23 +73,40 @@ class _ReactionButtonsState extends State<ReactionButtons> {
? selectedEmojis.skip(6).toList() ? selectedEmojis.skip(6).toList()
: []; : [];
final show = widget.show;
final targetBottom = widget.textInputFocused
? 50.0
: widget.mediaViewerDistanceFromBottom;
final bottomPosition = show
? targetBottom
: widget.mediaViewerDistanceFromBottom - 20;
final targetOpacity = show ? 1.0 : 0.0;
final isIgnoring = !show;
final positionDuration = show
? const Duration(milliseconds: 200)
: Duration.zero;
final opacityDuration = show
? const Duration(milliseconds: 150)
: Duration.zero;
return AnimatedPositioned( return AnimatedPositioned(
duration: const Duration(milliseconds: 200), // Animation duration duration: positionDuration, // Animation duration
bottom: widget.show bottom: bottomPosition,
? (widget.textInputFocused
? 50
: widget.mediaViewerDistanceFromBottom)
: widget.mediaViewerDistanceFromBottom - 20,
left: 0, left: 0,
right: 0, right: 0,
curve: Curves.linearToEaseOut, curve: Curves.linearToEaseOut,
child: IgnorePointer( child: IgnorePointer(
ignoring: !widget.show, ignoring: isIgnoring,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: widget.show ? 1.0 : 0.0, // Fade in/out opacity: targetOpacity, // Fade in/out
duration: Duration(milliseconds: widget.show ? 150 : 50), duration: opacityDuration,
child: Container( child: Container(
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, color: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 32), padding: const EdgeInsets.symmetric(vertical: 32),
child: Column( child: Column(
children: [ children: [

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:twonly/src/utils/misc.dart';
class TwonlyPresentOverlay extends StatelessWidget {
const TwonlyPresentOverlay({
required this.onTap,
super.key,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
onTap: onTap,
child: Column(
children: [
Expanded(
child: Lottie.asset(
'assets/animations/present.lottie.lottie',
),
),
Container(
padding: const EdgeInsets.only(bottom: 200),
child: Text(
context.lang.mediaViewerTwonlyTapToOpen,
),
),
],
),
),
);
}
}

View file

@ -3,9 +3,12 @@ import 'dart:async';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/providers/routing.provider.dart'; import 'package:twonly/src/providers/routing.provider.dart';
@ -38,6 +41,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
double _offsetFromOne = 0; double _offsetFromOne = 0;
bool _isBottomNavVisible = true; bool _isBottomNavVisible = true;
Timer? _disableCameraTimer; Timer? _disableCameraTimer;
bool _startPreloading = false;
final MainCameraController _mainCameraController = MainCameraController(); final MainCameraController _mainCameraController = MainCameraController();
late final PageController _homeViewPageController; late final PageController _homeViewPageController;
@ -73,7 +77,12 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
} }
setState(() { setState(() {
_activePageIdx = index; _activePageIdx = index;
_offsetFromOne = 1.0 - index;
_offsetRatio = _offsetFromOne.abs();
}); });
if (index != 1) {
unawaited(_mainCameraController.closeCamera());
}
}); });
_selectNotificationSub = selectNotificationStream.stream.listen(( _selectNotificationSub = selectNotificationStream.stream.listen((
@ -95,8 +104,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
}); });
if (initialPage == 1) { if (initialPage == 1) {
WidgetsBinding.instance.addPostFrameCallback((_) { Permission.camera.isGranted.then((hasPermission) {
if (_isViewActive()) { if (hasPermission && mounted) {
unawaited(_mainCameraController.selectCamera(0, true)); unawaited(_mainCameraController.selectCamera(0, true));
} }
}); });
@ -104,20 +113,26 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
unawaited(_initAsync()); unawaited(_initAsync());
void handleShareLink(Uri uri) {
routerProvider.go(Routes.home);
streamHomeViewPageIndex.add(1);
_mainCameraController.setSharedLinkForPreview(uri);
}
// Subscribe to all events (initial link and further) // Subscribe to all events (initial link and further)
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async { _deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
if (!mounted) return; if (!mounted) return;
Log.info('Got link via app links: ${uri.scheme}'); Log.info('Got link via app links: ${uri.scheme}');
if (!await handleIntentUrl(context, uri)) { if (!await handleIntentUrl(context, uri)) {
if (uri.scheme.startsWith('http')) { if (uri.scheme.startsWith('http')) {
_mainCameraController.setSharedLinkForPreview(uri); handleShareLink(uri);
} }
} }
}); });
_intentStreamSub = initIntentStreams( _intentStreamSub = initIntentStreams(
context, context,
_mainCameraController.setSharedLinkForPreview, handleShareLink,
); );
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -126,6 +141,13 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
widget.initialPage == 0) { widget.initialPage == 0) {
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
} }
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() {
_startPreloading = true;
});
}
});
}); });
} }
@ -156,11 +178,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
payload.startsWith(Routes.chats) && payload.startsWith(Routes.chats) &&
payload != Routes.chats) { payload != Routes.chats) {
routerProvider.go(payload); routerProvider.go(payload);
streamHomeViewPageIndex.add(0);
}
if (payload == Routes.chats) {
streamHomeViewPageIndex.add(0);
} }
streamHomeViewPageIndex.add(0);
} }
} }
@ -202,9 +221,12 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
if (_offsetRatio < 1 && if (AppState.hasCameraPermissions &&
_offsetRatio < 1 &&
!_mainCameraController.isSharePreviewIsShown && !_mainCameraController.isSharePreviewIsShown &&
_isViewActive()) { _isViewActive() &&
_mainCameraController.cameraController == null &&
!_mainCameraController.initCameraStarted) {
unawaited( unawaited(
_mainCameraController.selectCamera( _mainCameraController.selectCamera(
_mainCameraController.selectedCameraDetails.cameraId, _mainCameraController.selectedCameraDetails.cameraId,
@ -212,8 +234,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
), ),
); );
} }
} else if (state == AppLifecycleState.inactive || } else if (state == AppLifecycleState.paused) {
state == AppLifecycleState.paused) {
unawaited(_mainCameraController.closeCamera()); unawaited(_mainCameraController.closeCamera());
} }
} }
@ -255,14 +276,19 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
} }
} }
if (notification.depth == 0 && notification is ScrollUpdateNotification) { if (notification.depth == 0) {
setState(() { setState(() {
_offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0); _offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0);
_offsetRatio = _offsetFromOne.abs(); _offsetRatio = _offsetFromOne.abs();
final pageIndex = _homeViewPageController.page?.round();
if (pageIndex != null && pageIndex != _activePageIdx) {
_activePageIdx = pageIndex;
}
}); });
} }
if (_mainCameraController.cameraController == null && if (AppState.hasCameraPermissions &&
_mainCameraController.cameraController == null &&
!_mainCameraController.initCameraStarted && !_mainCameraController.initCameraStarted &&
_offsetRatio < 1 && _offsetRatio < 1 &&
_isViewActive()) { _isViewActive()) {
@ -301,17 +327,21 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
NotificationListener<ScrollNotification>( NotificationListener<ScrollNotification>(
onNotification: _onPageView, onNotification: _onPageView,
child: Positioned.fill( child: Positioned.fill(
child: PageView( child: CustomScrollView(
scrollDirection: Axis.horizontal,
physics: const PageScrollPhysics(),
controller: _homeViewPageController, controller: _homeViewPageController,
onPageChanged: (index) { scrollCacheExtent: _startPreloading
setState(() { ? const ScrollCacheExtent.viewport(1)
_activePageIdx = index; : null,
}); slivers: [
}, SliverFillViewport(
children: [ delegate: SliverChildListDelegate([
const ChatListView(), const ChatListView(),
Container(), Container(),
const MemoriesView(), const MemoriesView(),
]),
),
], ],
), ),
), ),

View file

@ -4,6 +4,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart'; import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart'; import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
class MemoriesThumbnailComp extends StatefulWidget { class MemoriesThumbnailComp extends StatefulWidget {
const MemoriesThumbnailComp({ const MemoriesThumbnailComp({
required this.galleryItem, required this.galleryItem,
@ -28,14 +29,7 @@ class MemoriesThumbnailComp extends StatefulWidget {
State<MemoriesThumbnailComp> createState() => _MemoriesThumbnailCompState(); State<MemoriesThumbnailComp> createState() => _MemoriesThumbnailCompState();
} }
final Set<String> _alreadyAnimatedIds = {}; class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
with SingleTickerProviderStateMixin {
late final AnimationController _scaleController;
late final Animation<double> _scaleAnimation;
late final Animation<Offset> _slideAnimation;
ImageProvider? _imageProvider; ImageProvider? _imageProvider;
ImageStream? _imageStream; ImageStream? _imageStream;
ImageInfo? _imageInfo; ImageInfo? _imageInfo;
@ -44,40 +38,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
_scaleAnimation = Tween<double>(begin: 0.94, end: 1).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic),
);
_slideAnimation =
Tween<Offset>(
begin: const Offset(0, 0.125),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic),
);
final mediaId = widget.galleryItem.mediaService.mediaFile.mediaId;
final shouldAnimate =
widget.index < 20 && !_alreadyAnimatedIds.contains(mediaId);
if (shouldAnimate) {
_alreadyAnimatedIds.add(mediaId);
final delayMs = widget.index * 10;
if (delayMs > 0) {
Future.delayed(Duration(milliseconds: delayMs), () {
if (mounted) {
_scaleController.forward();
}
});
} else {
_scaleController.forward();
}
} else {
_scaleController.value = 1.0;
}
_listener = ImageStreamListener( _listener = ImageStreamListener(
(info, _) { (info, _) {
@ -101,8 +61,11 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
void _resolveImage() { void _resolveImage() {
final media = widget.galleryItem.mediaService; final media = widget.galleryItem.mediaService;
final hasThumbnail = media.thumbnailPath.existsSync() && media.thumbnailPath.lengthSync() > 0; final hasThumbnail =
final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0; media.thumbnailPath.existsSync() &&
media.thumbnailPath.lengthSync() > 0;
final hasStored =
media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
final isImageOrGif = final isImageOrGif =
media.mediaFile.type == MediaType.image || media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif; media.mediaFile.type == MediaType.gif;
@ -136,7 +99,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
@override @override
void dispose() { void dispose() {
_scaleController.dispose();
_imageStream?.removeListener(_listener); _imageStream?.removeListener(_listener);
super.dispose(); super.dispose();
} }
@ -169,80 +131,71 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
); );
} }
: null, : null,
child: SlideTransition( child: SelectableThumbnailComp(
position: _slideAnimation, isSelected: widget.isSelected,
child: ScaleTransition( selectionMode: widget.selectionMode,
scale: _scaleAnimation, child: Stack(
child: FadeTransition( fit: StackFit.expand,
opacity: _scaleController, children: [
child: SelectableThumbnailComp( if (cachedInfo != null)
isSelected: widget.isSelected, RawImage(
selectionMode: widget.selectionMode, image: cachedInfo.image,
child: Stack( fit: BoxFit.cover,
fit: StackFit.expand, )
children: [ else if (_imageProvider != null)
if (cachedInfo != null) Image(
RawImage( image: _imageProvider!,
image: cachedInfo.image, fit: BoxFit.cover,
fit: BoxFit.cover, gaplessPlayback: true,
) errorBuilder: (context, error, stackTrace) {
else if (_imageProvider != null) return ColoredBox(
Image( color: Colors.grey.shade200,
image: _imageProvider!, child: const Center(
fit: BoxFit.cover, child: FaIcon(
gaplessPlayback: true, FontAwesomeIcons.image,
errorBuilder: (context, error, stackTrace) { color: Colors.black26,
return ColoredBox(
color: Colors.grey.shade200,
child: const Center(
child: FaIcon(
FontAwesomeIcons.image,
color: Colors.black26,
),
),
);
},
)
else
ColoredBox(
color: Colors.grey.shade200,
child: const Center(
child: FaIcon(
FontAwesomeIcons.image,
color: Colors.black26,
),
), ),
), ),
if (isVideo) );
const Positioned.fill( },
child: Center( )
child: FaIcon( else
FontAwesomeIcons.circlePlay, ColoredBox(
color: Colors.white, color: Colors.grey.shade200,
size: 32, child: const Center(
shadows: [ child: FaIcon(
Shadow(color: Colors.black54, blurRadius: 6), FontAwesomeIcons.image,
], color: Colors.black26,
), ),
), ),
),
if (media.mediaFile.isFavorite)
const Positioned(
bottom: 6,
left: 6,
child: Icon(
Icons.favorite,
color: Colors.redAccent,
size: 16,
shadows: [
Shadow(color: Colors.black54, blurRadius: 4),
],
),
),
],
), ),
), if (isVideo)
), const Positioned.fill(
child: Center(
child: FaIcon(
FontAwesomeIcons.circlePlay,
color: Colors.white,
size: 32,
shadows: [
Shadow(color: Colors.black54, blurRadius: 6),
],
),
),
),
if (media.mediaFile.isFavorite)
const Positioned(
bottom: 6,
left: 6,
child: Icon(
Icons.favorite,
color: Colors.redAccent,
size: 16,
shadows: [
Shadow(color: Colors.black54, blurRadius: 4),
],
),
),
],
), ),
), ),
); );

View file

@ -21,7 +21,7 @@ class MemoriesView extends StatefulWidget {
State<MemoriesView> createState() => MemoriesViewState(); State<MemoriesView> createState() => MemoriesViewState();
} }
class MemoriesViewState extends State<MemoriesView> { class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClientMixin<MemoriesView> {
late final MemoriesService _service; late final MemoriesService _service;
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null); final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@ -38,6 +38,9 @@ class MemoriesViewState extends State<MemoriesView> {
_activeMediaIdNotifier.addListener(_onActiveMediaChanged); _activeMediaIdNotifier.addListener(_onActiveMediaChanged);
} }
@override
bool get wantKeepAlive => true;
@override @override
void dispose() { void dispose() {
_activeMediaIdNotifier.removeListener(_onActiveMediaChanged); _activeMediaIdNotifier.removeListener(_onActiveMediaChanged);
@ -307,11 +310,18 @@ class MemoriesViewState extends State<MemoriesView> {
if (item != null) { if (item != null) {
final media = item.mediaService; final media = item.mediaService;
if (media.mediaFile.type == MediaType.video) { if (media.mediaFile.type == MediaType.video) {
await saveVideoToGallery(media.storedPath.path); await saveVideoToGallery(
media.storedPath.path,
name: media.mediaFile.mediaId,
);
} else if (media.mediaFile.type == MediaType.image || } else if (media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif) { media.mediaFile.type == MediaType.gif) {
final imageBytes = await media.storedPath.readAsBytes(); final imageBytes = await media.storedPath.readAsBytes();
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt); await saveImageToGallery(
imageBytes,
createdAt: media.mediaFile.createdAt,
name: media.mediaFile.mediaId,
);
} }
} }
setProgress((i + 1) / selectedList.length); setProgress((i + 1) / selectedList.length);
@ -369,6 +379,7 @@ class MemoriesViewState extends State<MemoriesView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
return Scaffold( return Scaffold(
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,

View file

@ -193,11 +193,18 @@ class _SynchronizedImageViewerScreenState
try { try {
if (item.mediaFile.type == MediaType.video) { if (item.mediaFile.type == MediaType.video) {
await saveVideoToGallery(item.storedPath.path); await saveVideoToGallery(
item.storedPath.path,
name: item.mediaFile.mediaId,
);
} else if (item.mediaFile.type == MediaType.image || } else if (item.mediaFile.type == MediaType.image ||
item.mediaFile.type == MediaType.gif) { item.mediaFile.type == MediaType.gif) {
final imageBytes = await item.storedPath.readAsBytes(); final imageBytes = await item.storedPath.readAsBytes();
await saveImageToGallery(imageBytes, createdAt: item.mediaFile.createdAt); await saveImageToGallery(
imageBytes,
createdAt: item.mediaFile.createdAt,
name: item.mediaFile.mediaId,
);
} }
if (!mounted) return; if (!mounted) return;
showSnackbar( showSnackbar(

View file

@ -13,6 +13,7 @@ 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/services/android_photo_picker.service.dart'; import 'package:twonly/src/services/android_photo_picker.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File; import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart'; import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
@ -84,8 +85,8 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
final hash = Uint8List.fromList(sha256.convert(bytes).bytes); final hash = Uint8List.fromList(sha256.convert(bytes).bytes);
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash); final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
if (exsits.isNotEmpty) { if (exists.isNotEmpty) {
duplicated += 1; duplicated += 1;
continue; continue;
} }
@ -345,8 +346,8 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
final hash = Uint8List.fromList(await sha256File(file)); final hash = Uint8List.fromList(await sha256File(file));
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash); final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
if (exsits.isNotEmpty) { if (exists.isNotEmpty) {
duplicated += 1; duplicated += 1;
continue; continue;
} }
@ -363,14 +364,50 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
type = MediaType.image; type = MediaType.image;
} }
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia( MediaFile? mediaFile;
MediaFilesCompanion( var isRestored = false;
type: Value(type),
createdAt: Value(createdAt), if (Platform.isIOS) {
storedFileHash: Value(hash), try {
stored: const Value(true), final assetName = await asset.titleAsync;
), final dotIndex = assetName.lastIndexOf('.');
); final baseName = dotIndex != -1 ? assetName.substring(0, dotIndex) : assetName;
final uuidRegex = RegExp(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
);
if (uuidRegex.hasMatch(baseName)) {
final existing = await twonlyDB.mediaFilesDao.getMediaFileById(baseName);
if (existing != null) {
final mediaService = MediaFileService(existing);
if (!mediaService.storedPath.existsSync()) {
await twonlyDB.mediaFilesDao.updateMedia(
baseName,
MediaFilesCompanion(
storedFileHash: Value(hash),
stored: const Value(true),
),
);
mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(baseName);
isRestored = true;
}
}
}
} catch (e) {
Log.error('Error checking iOS asset UUID name: $e');
}
}
if (!isRestored) {
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
MediaFilesCompanion(
type: Value(type),
createdAt: Value(createdAt),
storedFileHash: Value(hash),
stored: const Value(true),
),
);
}
if (mediaFile != null) { if (mediaFile != null) {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);

View file

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
List<Widget> parseMarkdown(BuildContext context, String markdown) { List<Widget> parseMarkdown(BuildContext context, String markdown) {
@ -107,20 +106,6 @@ class _ChangeLogViewState extends State<ChangeLogView> {
), ),
), ),
), ),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(context.lang.openChangeLog),
Switch.adaptive(
value: !userService.currentUser.hideChangeLog,
onChanged: (_) => UserService.update(
(u) => u.hideChangeLog = !u.hideChangeLog,
),
),
],
),
),
); );
}, },
); );

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.3.1+145 version: 0.3.3+147
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0