diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b7d48c..5888736d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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 - New: Promotion of sharing contacts when contact is new to twonly diff --git a/lib/main.dart b/lib/main.dart index ea7a244c..b1114ec1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mutex/mutex.dart'; @@ -32,7 +33,7 @@ import 'package:twonly/src/utils/startup_guard.dart'; 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. Future twonlyMinimumInitialization() async { Log.info('twonlyMinimumInitialization: called'); @@ -68,12 +69,21 @@ Future twonlyMinimumInitialization() async { void main() async { final binding = SentryWidgetsFlutterBinding.ensureInitialized(); 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(); unawaited(StartupGuard.markAppStartup()); var storageError = await twonlyMinimumInitialization(); await FcmNotificationService.initStartup(); + await setupPushNotification(); var userExists = false; @@ -163,7 +173,6 @@ Future postStartupTasks() async { unawaited(MediaFileService.purgeTempFolder()); // 2. Service initializations - unawaited(setupPushNotification()); unawaited(finishStartedPreprocessing()); unawaited(createPushAvatars()); diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index ba87a485..e0b9c585 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -314,11 +314,15 @@ class MediaFileService { await tempPath.copy(storedPath.path); if (userService.currentUser.storeMediaFilesInGallery) { if (mediaFile.type == MediaType.video) { - await saveVideoToGallery(storedPath.path); + await saveVideoToGallery( + storedPath.path, + name: mediaFile.mediaId, + ); } else { await saveImageToGallery( storedPath.readAsBytesSync(), createdAt: mediaFile.createdAt, + name: mediaFile.mediaId, ); } } diff --git a/lib/src/services/notifications/background.notifications.dart b/lib/src/services/notifications/background.notifications.dart index fcf5afa7..b4912175 100644 --- a/lib/src/services/notifications/background.notifications.dart +++ b/lib/src/services/notifications/background.notifications.dart @@ -159,6 +159,7 @@ Future showLocalPushNotification( ticker: 'You got a new message.', largeIcon: styleInformation, icon: 'ic_launcher_foreground', + groupKey: 'com.twonly.messages', ); const darwinNotificationDetails = DarwinNotificationDetails(); @@ -190,43 +191,29 @@ Future showLocalPushNotification( notificationDetails, payload: payload, ); -} -Future showLocalPushNotificationWithoutUserId( - PushNotification pushNotification, -) async { - final lang = getLocalizations(); - - var title = lang.notificationTitleUnknown; - var body = lang.notificationBodyUnknown; - - if (pushNotification.kind == PushKind.CONTACT_REQUEST) { - title = lang.you; - body = lang.notificationContactRequestUnknownUser; + if (Platform.isAndroid) { + final summaryAndroidDetails = AndroidNotificationDetails( + '0', + lang.notificationCategoryMessageTitle, + channelDescription: lang.notificationCategoryMessageDesc, + importance: Importance.max, + priority: Priority.max, + groupKey: 'com.twonly.messages', + setAsGroupSummary: true, + icon: 'ic_launcher_foreground', + ); + 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 getAvatarIcon(int contactId) async { diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index b11db174..0ddc1fbe 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -11,6 +11,7 @@ import 'package:gal/gal.dart'; import 'package:image/image.dart' as img; import 'package:intl/intl.dart'; import 'package:local_auth/local_auth.dart'; +import 'package:path/path.dart'; import 'package:provider/provider.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; @@ -36,6 +37,7 @@ extension ShortCutsExtension on BuildContext { Future saveImageToGallery( Uint8List imageBytes, { DateTime? createdAt, + String? name, }) async { var bytesToProcess = imageBytes; @@ -75,7 +77,7 @@ Future saveImageToGallery( await Gal.requestAccess(toAlbum: true); } try { - await Gal.putImageBytes(jpgImages, album: 'twonly'); + await Gal.putImageBytes(jpgImages, album: 'twonly', name: name ?? 'image'); return null; } on GalException catch (e) { Log.error(e); @@ -83,17 +85,45 @@ Future saveImageToGallery( } } -Future saveVideoToGallery(String videoPath) async { +Future saveVideoToGallery( + String videoPath, { + String? name, +}) async { final hasAccess = await Gal.hasAccess(toAlbum: true); if (!hasAccess) { await Gal.requestAccess(toAlbum: true); } + + var pathToSave = videoPath; + File? tempFile; + 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; } on GalException catch (e) { Log.error(e); return e.type.message; + } finally { + if (tempFile != null && tempFile.existsSync()) { + try { + tempFile.deleteSync(); + } catch (e) { + Log.error('Failed to delete temp video file: $e'); + } + } } } diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index c0dfa7d1..94766724 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -10,6 +10,7 @@ enum MyButtonVariant { primaryMiddle, primaryDense, secondaryDense, + secondaryMiddle, error, } @@ -212,6 +213,25 @@ class _MyButtonState extends State 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: buttonStyle = FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.errorContainer, diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart index deb27d62..49810100 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -108,6 +108,7 @@ class _CameraPreviewControllerViewState ); } else { return PermissionHandlerView( + triggerPermissionRequest: widget.isVisible, onSuccess: () { setState(() { AppState.hasCameraPermissions = true; diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index 02f5dab0..8f722fc4 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -134,14 +134,25 @@ class MainCameraController { } Future selectCamera(int sCameraId, bool init) async { + if (initCameraStarted) return; initCameraStarted = true; final sessionId = ++_cameraSessionId; - await _pendingDisposal; - if (sessionId != _cameraSessionId) return; - if (AppEnvironment.cameras.isEmpty) { - AppEnvironment.cameras = await availableCameras(); + // Start checking microphone permission concurrently + final micPermissionFuture = Permission.microphone.isGranted; + + try { + await _pendingDisposal; 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; @@ -149,6 +160,7 @@ class MainCameraController { Log.warn( 'Trying to select a non existing camera $cameraId >= ${AppEnvironment.cameras.length}', ); + initCameraStarted = false; return; } @@ -159,12 +171,21 @@ class MainCameraController { break; } } + if (cameraId >= AppEnvironment.cameras.length) { + cameraId = sCameraId; + } } selectedCameraDetails.isZoomAble = false; - if (cameraController == null) { - final hasMic = await Permission.microphone.isGranted; + if (cameraController == null || !cameraController!.value.isInitialized) { + final controllerToDispose = cameraController; + cameraController = null; + if (controllerToDispose != null) { + unawaited(controllerToDispose.dispose()); + } + + final hasMic = await micPermissionFuture; if (sessionId != _cameraSessionId) return; cameraController = CameraController( @@ -178,58 +199,55 @@ class MainCameraController { try { _initializeFuture = cameraController?.initialize(); await _initializeFuture; - if (cameraController == null) return; - await cameraController?.startImageStream(_processCameraImage); - if (cameraController == null) return; - await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); - if (cameraController == null) return; + await cameraController!.startImageStream(_processCameraImage); + await cameraController!.setZoomLevel(selectedCameraDetails.scaleFactor); if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) { - await cameraController?.setVideoStabilizationMode( + await cameraController!.setVideoStabilizationMode( VideoStabilizationMode.level1, ); } } catch (e) { + Log.error('Error initializing camera: $e'); + final controllerToDispose = cameraController; cameraController = null; + if (controllerToDispose != null) { + unawaited(controllerToDispose.dispose()); + } + initCameraStarted = false; return; } } else { try { if (!isVideoRecording) { - await cameraController?.stopImageStream(); + await cameraController!.stopImageStream(); } - } catch (e) { - Log.info(e); - } - if (cameraController == null) return; - selectedCameraDetails.scaleFactor = 1; + selectedCameraDetails.scaleFactor = 1; - await cameraController?.setZoomLevel(1); - if (cameraController == null) return; - await cameraController?.setDescription(AppEnvironment.cameras[cameraId]); - if (cameraController == null) return; - try { + await cameraController!.setZoomLevel(1); + await cameraController!.setDescription( + AppEnvironment.cameras[cameraId], + ); if (!isVideoRecording) { - await cameraController?.startImageStream(_processCameraImage); + await cameraController!.startImageStream(_processCameraImage); } } catch (e) { - Log.info(e); + Log.error('Error switching camera description: $e'); + initCameraStarted = false; + return; } } try { - if (cameraController == null) return; - await cameraController?.lockCaptureOrientation( + await cameraController!.lockCaptureOrientation( DeviceOrientation.portraitUp, ); - if (cameraController == null) return; - await cameraController?.setFlashMode( + await cameraController!.setFlashMode( selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, ); - if (cameraController == null) return; - selectedCameraDetails.maxAvailableZoom = - await cameraController?.getMaxZoomLevel() ?? 1; - selectedCameraDetails.minAvailableZoom = - await cameraController?.getMinZoomLevel() ?? 1; + selectedCameraDetails.maxAvailableZoom = await cameraController! + .getMaxZoomLevel(); + selectedCameraDetails.minAvailableZoom = await cameraController! + .getMinZoomLevel(); selectedCameraDetails ..isZoomAble = selectedCameraDetails.maxAvailableZoom != @@ -242,10 +260,16 @@ class MainCameraController { isSelectingFaceFilters = false; setFilter(FaceFilterType.none); zoomButtonKey = GlobalKey(); + initCameraStarted = false; setState?.call(); } catch (e) { - Log.error(e); + Log.error('Error post-initializing camera: $e'); + final controllerToDispose = cameraController; cameraController = null; + if (controllerToDispose != null) { + unawaited(controllerToDispose.dispose()); + } + initCameraStarted = false; return; } } @@ -312,7 +336,6 @@ class MainCameraController { } final inputImage = _inputImageFromCameraImage(image); if (inputImage == null) return; - _processBarcode(inputImage); // check if front camera is selected if (cameraController?.description.lensDirection == CameraLensDirection.front) { @@ -462,7 +485,10 @@ class MainCameraController { scannedUrl = link; if (sharedLinkForPreview == null) { timeSharedLinkWasSetWithQr = clock.now(); - setSharedLinkForPreview(Uri.parse(scannedUrl!), generatePreview: false); + setSharedLinkForPreview( + Uri.parse(scannedUrl!), + generatePreview: false, + ); } } } diff --git a/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart b/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart index fa14088e..fe90a189 100644 --- a/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart @@ -2,12 +2,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; 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 bool triggerPermissionRequest; @override PermissionHandlerViewState createState() => PermissionHandlerViewState(); @@ -17,10 +23,6 @@ Future checkPermissions() async { if (!await Permission.camera.isGranted) { return false; } - if (!await Permission.microphone.isGranted) { - // microphone is only needed when - return true; - } return true; } @@ -36,6 +38,32 @@ class PermissionHandlerViewState extends State _timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { 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 _requestPermissions() async { + try { + await permissionServices(); + if (await checkPermissions()) { + _handleSuccess(); + } + } catch (e) { + Log.error(e); + } } @override @@ -54,21 +82,32 @@ class PermissionHandlerViewState extends State Future _checkAndTriggerSuccess() async { if (_isSuccessTriggered) return; + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) { + return; + } try { if (await checkPermissions()) { - _isSuccessTriggered = true; - _timer?.cancel(); - // ignore: avoid_dynamic_calls - widget.onSuccess(); + _handleSuccess(); } } catch (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> permissionServices() async { final statuses = await [ Permission.camera, + Permission.microphone, Permission.notification, ].request(); @@ -100,8 +139,7 @@ class PermissionHandlerViewState extends State try { await permissionServices(); if (await checkPermissions()) { - // ignore: avoid_dynamic_calls - widget.onSuccess(); + _handleSuccess(); } } catch (e) { Log.error(e); diff --git a/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart b/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart index 98d8778c..c57a780d 100644 --- a/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart +++ b/lib/src/visual/views/camera/camera_preview_components/save_to_gallery.dart @@ -36,7 +36,7 @@ class SaveToGalleryButtonState extends State { Widget build(BuildContext context) { final isEnabled = !widget.isLoading && !_imageSaving; return MyButton( - variant: MyButtonVariant.secondaryDense, + variant: MyButtonVariant.secondaryMiddle, onPressed: isEnabled ? () async { setState(() { diff --git a/lib/src/visual/views/camera/camera_qr_scanner.view.dart b/lib/src/visual/views/camera/camera_qr_scanner.view.dart index 87614e4b..06aa8ee9 100644 --- a/lib/src/visual/views/camera/camera_qr_scanner.view.dart +++ b/lib/src/visual/views/camera/camera_qr_scanner.view.dart @@ -1,5 +1,6 @@ import 'dart:async'; 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_controller_view.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; @@ -19,7 +20,11 @@ class QrCodeScannerViewState extends State { _mainCameraController.setState = () { if (mounted) setState(() {}); }; - unawaited(_mainCameraController.selectCamera(0, true)); + Permission.camera.isGranted.then((hasPermission) { + if (hasPermission && mounted) { + unawaited(_mainCameraController.selectCamera(0, true)); + } + }); } @override @@ -44,10 +49,12 @@ class QrCodeScannerViewState extends State { onTapDown: _mainCameraController.onTapDown, ), ), - CameraPreviewControllerView( - mainController: _mainCameraController, - hideControllers: true, - isVisible: true, + Positioned.fill( + child: CameraPreviewControllerView( + mainController: _mainCameraController, + hideControllers: true, + isVisible: true, + ), ), ], ), diff --git a/lib/src/visual/views/camera/camera_send_to.view.dart b/lib/src/visual/views/camera/camera_send_to.view.dart index 5e063540..389c2423 100644 --- a/lib/src/visual/views/camera/camera_send_to.view.dart +++ b/lib/src/visual/views/camera/camera_send_to.view.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.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_controller_view.dart'; @@ -21,7 +22,11 @@ class CameraSendToViewState extends State { _mainCameraController.setState = () { if (mounted) setState(() {}); }; - unawaited(_mainCameraController.selectCamera(0, true)); + Permission.camera.isGranted.then((hasPermission) { + if (hasPermission && mounted) { + unawaited(_mainCameraController.selectCamera(0, true)); + } + }); } @override @@ -46,10 +51,12 @@ class CameraSendToViewState extends State { onTapDown: _mainCameraController.onTapDown, ), ), - CameraPreviewControllerView( - mainController: _mainCameraController, - sendToGroup: widget.sendToGroup, - isVisible: true, + Positioned.fill( + child: CameraPreviewControllerView( + mainController: _mainCameraController, + sendToGroup: widget.sendToGroup, + isVisible: true, + ), ), ], ), diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index 34dbd2ed..afa4382e 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -81,7 +81,8 @@ class _ShareImageEditorView extends State { layers.add(FilterLayerData(key: GlobalKey())); } - if (widget.previewLink != null && widget.previewLink!.shouldGeneratePreview) { + if (widget.previewLink != null && + widget.previewLink!.shouldGeneratePreview) { layers.add( LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url), ); @@ -724,7 +725,7 @@ class _ShareImageEditorView extends State { if (widget.sendToGroup != null) const SizedBox(width: 10), if (widget.sendToGroup != null) MyButton( - variant: MyButtonVariant.secondaryDense, + variant: MyButtonVariant.secondaryMiddle, onPressed: pushShareImageView, child: const FaIcon( FontAwesomeIcons.userPlus, diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index e119d60d..2b7e3f30 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -1,9 +1,6 @@ import 'dart:async'; -import 'package:cryptography_plus/cryptography_plus.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.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/services/mediafiles/mediafile.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/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -32,7 +28,7 @@ class ChatListView extends StatefulWidget { State createState() => _ChatListViewState(); } -class _ChatListViewState extends State { +class _ChatListViewState extends State with AutomaticKeepAliveClientMixin { StreamSubscription? _userSub; StreamSubscription>? _contactsSub; StreamSubscription>? _contactsCountSub; @@ -126,32 +122,11 @@ class _ChatListViewState extends State { } } }); - - 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 void dispose() { _contactsSub?.cancel(); @@ -165,6 +140,7 @@ class _ChatListViewState extends State { @override Widget build(BuildContext context) { + super.build(context); final plan = context.watch().plan; return Scaffold( appBar: AppBar( diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index ef0e34e0..403f0fe4 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -1,15 +1,12 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:math'; import 'package:clock/clock.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; -import 'package:lottie/lottie.dart'; import 'package:mutex/mutex.dart'; -import 'package:photo_view/photo_view.dart'; import 'package:screen_protector/screen_protector.dart'; import 'package:twonly/globals.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/visual/components/animate_icon.comp.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/loader/three_rotating_dots.loader.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/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/twonly_present_overlay.comp.dart'; import 'package:video_player/video_player.dart'; class MediaViewerView extends StatefulWidget { @@ -64,6 +63,8 @@ class _MediaViewerViewState extends State { DateTime? canBeSeenUntil; final ValueNotifier progress = ValueNotifier(0); bool showSendTextMessageInput = false; + double maxBottomInset = 0; + DateTime? _lastTimeInputClosed; final GlobalKey mediaWidgetKey = GlobalKey(); bool imageSaved = false; @@ -82,9 +83,6 @@ class _MediaViewerViewState extends State { final HashSet _alreadyOpenedMediaIds = HashSet(); bool _isTransitioning = false; - bool _isZoomed = false; - late PageController _verticalPager; - final ValueNotifier _backdropOpacityNotifier = ValueNotifier(1); @override void initState() { @@ -95,12 +93,7 @@ class _MediaViewerViewState extends State { allMediaFiles = [widget.initialMessage!]; } - _verticalPager = PageController(initialPage: 1); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated); - }); - - asyncLoadNextMedia(true); + listenForUnopenedMedia(true); } @override @@ -127,21 +120,9 @@ class _MediaViewerViewState extends State { ); textMessageController.dispose(); - _verticalPager - ..removeListener(_onVerticalScrollUpdated) - ..dispose(); - _backdropOpacityNotifier.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() { final listener = _videoListener; final controller = videoController; @@ -160,7 +141,7 @@ class _MediaViewerViewState extends State { (ModalRoute.of(context)?.isCurrent ?? false); } - Future asyncLoadNextMedia(bool firstRun) async { + Future listenForUnopenedMedia(bool firstRun) async { _subscription = twonlyDB.messagesDao .watchMediaNotOpened(widget.group.groupId) .listen((messages) async { @@ -174,7 +155,7 @@ class _MediaViewerViewState extends State { } 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; } @@ -207,13 +188,13 @@ class _MediaViewerViewState extends State { if (mounted) setState(() {}); if (firstRun) { firstRun = false; - await loadCurrentMediaFile(); + await loadAndDownloadCurrentMedia(); } }); }); } - Future nextMediaOrExit() async { + Future advanceToNextMediaOrExit() async { if (_isTransitioning) return; _isTransitioning = true; @@ -245,17 +226,17 @@ class _MediaViewerViewState extends State { } } } else { - await loadCurrentMediaFile(); + await loadAndDownloadCurrentMedia(); } } finally { if (mounted) _isTransitioning = false; } } - Future loadCurrentMediaFile({bool showTwonly = false}) async { + Future loadAndDownloadCurrentMedia({bool showTwonly = false}) async { if (!mounted || !context.mounted) return; if (allMediaFiles.isEmpty || allMediaFiles.first.mediaId == null) { - return nextMediaOrExit(); + return advanceToNextMediaOrExit(); } try { @@ -293,7 +274,7 @@ class _MediaViewerViewState extends State { // Media file record no longer exists — skip to next or exit rather // than leaving the screen permanently black with no content/loader. await downloadStateListener?.cancel(); - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); return; } if (updated.downloadState != DownloadState.ready) { @@ -308,7 +289,7 @@ class _MediaViewerViewState extends State { if (mediaFile == null) { // DB record gone — skip to next or exit. await downloadStateListener?.cancel(); - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); return; } await startDownloadMedia(mediaFile, true); @@ -319,18 +300,16 @@ class _MediaViewerViewState extends State { await downloadStateListener?.cancel(); try { - await handleNextDownloadedMedia(showTwonly); + await initializeAndDisplayCurrentMedia(showTwonly); } catch (e, st) { - Log.error('handleNextDownloadedMedia failed: $e\n$st'); - await nextMediaOrExit(); + Log.error('initializeAndDisplayCurrentMedia failed: $e\n$st'); + await advanceToNextMediaOrExit(); } // start downloading all the other possible missing media files. }); } - Future handleNextDownloadedMedia( - bool showTwonly, - ) async { + Future initializeAndDisplayCurrentMedia(bool showTwonly) async { if (allMediaFiles.isEmpty) return; setState(() { _showDownloadingLoader = false; @@ -355,7 +334,7 @@ class _MediaViewerViewState extends State { if (!mounted) return; if (!isAuth) { - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); if (mounted) { setState(() { displayTwonlyPresent = false; @@ -372,25 +351,55 @@ class _MediaViewerViewState extends State { displayTwonlyPresent = false; }); - if (!widget.group.isDirectChat) { - final sender = await twonlyDB.contactsDao.getContactById( - currentMessage!.senderId!, - ); + await _updateSenderInfo(); + if (!mounted) return; - if (!mounted) return; + await _notifyMessageOpened(currentMediaLocal); + if (!mounted) return; - if (sender != null) { - _currentMediaSender = - '${getContactDisplayName(sender)} (${widget.group.groupName})'; - } + if (!currentMediaLocal.tempPath.existsSync()) { + Log.error('Temp media file not found...'); + 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 _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 _notifyMessageOpened(MediaFileService mediaLocal) async { + if (currentMessage == null) return; var markAsOpenMessageIDs = [currentMessage!.messageId]; if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened && - currentMediaLocal.mediaFile.storedFileHash != null) { + mediaLocal.mediaFile.storedFileHash != null) { final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash( - currentMediaLocal.mediaFile.storedFileHash!, + mediaLocal.mediaFile.storedFileHash!, currentMessage!.senderId!, ); @@ -408,106 +417,82 @@ class _MediaViewerViewState extends State { currentMessage!.senderId!, markAsOpenMessageIDs, ); + } - if (!mounted) return; - - if (!currentMediaLocal.tempPath.existsSync()) { - Log.error('Temp media file not found...'); - await handleMediaError(currentMediaLocal.mediaFile); - return nextMediaOrExit(); - } - - // The server can now delete the encrypted bytes, as the users has sucessfully opened it. - unawaited( - apiService.downloadDone(currentMediaLocal.mediaFile.downloadToken!), + Future _setupVideoPlayer(MediaFileService mediaLocal) async { + final controller = VideoPlayerController.file( + mediaLocal.tempPath, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: mediaLocal.mediaFile.displayLimitInMilliseconds == null, + ), ); - var timerRequired = false; + await controller.setLooping( + mediaLocal.mediaFile.displayLimitInMilliseconds == null, + ); - if (currentMediaLocal.mediaFile.type == MediaType.video) { - final controller = VideoPlayerController.file( - currentMediaLocal.tempPath, - videoPlayerOptions: VideoPlayerOptions( - // only mix in case the video can be played multiple times, - // otherwise stop the background music in case the video contains audio - mixWithOthers: - currentMediaLocal.mediaFile.displayLimitInMilliseconds == null, + 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 (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!, ), ); - - await controller.setLooping( - 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(); + if (mounted) { + startProgressTimer(); } } } - void startTimer() { + void startProgressTimer() { nextMediaTimer?.cancel(); progressTimer?.cancel(); if (canBeSeenUntil != null) { nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () { if (context.mounted) { - nextMediaOrExit(); + advanceToNextMediaOrExit(); } }); progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { @@ -653,7 +638,7 @@ class _MediaViewerViewState extends State { ); if (mounted && currentMedia!.mediaFile.displayLimitInMilliseconds != null) { - await nextMediaOrExit(); + await advanceToNextMediaOrExit(); } else { await videoController?.play(); } @@ -677,21 +662,63 @@ class _MediaViewerViewState extends State { ); } - 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) { setState(() { showShortReactions = false; showSendTextMessageInput = false; + _lastTimeInputClosed = clock.now(); }); return; } - nextMediaOrExit(); + advanceToNextMediaOrExit(); } @override 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( - backgroundColor: Colors.transparent, body: SafeArea( child: Stack( fit: StackFit.expand, @@ -700,93 +727,21 @@ class _MediaViewerViewState extends State { if ((currentMedia != null || videoController != null) && (canBeSeenUntil == null || progress.value >= 0)) GestureDetector( - onTap: onTap, - onDoubleTap: (videoController == null) ? null : onTap, + onTap: onScreenTapped, + onDoubleTap: (videoController == null) ? null : onScreenTapped, child: MediaViewSizingHelper( bottomNavigation: bottomNavigation(), requiredHeight: 55, - child: Stack( - children: [ - if (videoController != null) - Positioned.fill( - 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, - ), - ); - }, - ), - ), - ], + child: MediaContentRenderer( + currentMedia: currentMedia, + videoController: videoController, + loader: _loader(), ), ), ), if (displayTwonlyPresent) - Positioned.fill( - child: GestureDetector( - 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, - ), - ), - ], - ), - ), + TwonlyPresentOverlay( + onTap: () => loadAndDownloadCurrentMedia(showTwonly: true), ), if (currentMedia != null && currentMedia?.mediaFile.downloadState != DownloadState.ready) @@ -836,63 +791,10 @@ class _MediaViewerViewState extends State { ), ), if (showSendTextMessageInput) - 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: 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; - }); - }, - ), - ], - ), - ), + MediaViewerMessageInput( + controller: textMessageController, + onSubmitted: (value) => _sendTextMessage(), + onSendPressed: _sendTextMessage, ), if (currentMessage != null) AdditionalMessageContent(currentMessage!), @@ -908,6 +810,7 @@ class _MediaViewerViewState extends State { setState(() { showShortReactions = false; showSendTextMessageInput = false; + _lastTimeInputClosed = clock.now(); }); }, ), diff --git a/lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart b/lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart new file mode 100644 index 00000000..0033f4eb --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart @@ -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, + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart b/lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart new file mode 100644 index 00000000..d69cef2c --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart @@ -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 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart index ac7f34e8..71d419fa 100644 --- a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart +++ b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart @@ -37,7 +37,6 @@ class ReactionButtons extends StatefulWidget { } class _ReactionButtonsState extends State { - int selectedShortReaction = -1; final GlobalKey _keyEmojiPicker = GlobalKey(); bool _renderAnimations = false; @@ -74,23 +73,40 @@ class _ReactionButtonsState extends State { ? 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( - duration: const Duration(milliseconds: 200), // Animation duration - bottom: widget.show - ? (widget.textInputFocused - ? 50 - : widget.mediaViewerDistanceFromBottom) - : widget.mediaViewerDistanceFromBottom - 20, + duration: positionDuration, // Animation duration + bottom: bottomPosition, left: 0, right: 0, curve: Curves.linearToEaseOut, child: IgnorePointer( - ignoring: !widget.show, + ignoring: isIgnoring, child: AnimatedOpacity( - opacity: widget.show ? 1.0 : 0.0, // Fade in/out - duration: Duration(milliseconds: widget.show ? 150 : 50), + opacity: targetOpacity, // Fade in/out + duration: opacityDuration, child: Container( - color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, + color: Colors.transparent, padding: const EdgeInsets.symmetric(vertical: 32), child: Column( children: [ diff --git a/lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart b/lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart new file mode 100644 index 00000000..7fb71011 --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index c51f3e0a..f6cfdcba 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -3,9 +3,12 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_sharing_intent/model/sharing_file.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/src/constants/routes.keys.dart'; import 'package:twonly/src/providers/routing.provider.dart'; @@ -38,6 +41,7 @@ class HomeViewState extends State with WidgetsBindingObserver { double _offsetFromOne = 0; bool _isBottomNavVisible = true; Timer? _disableCameraTimer; + bool _startPreloading = false; final MainCameraController _mainCameraController = MainCameraController(); late final PageController _homeViewPageController; @@ -73,7 +77,12 @@ class HomeViewState extends State with WidgetsBindingObserver { } setState(() { _activePageIdx = index; + _offsetFromOne = 1.0 - index; + _offsetRatio = _offsetFromOne.abs(); }); + if (index != 1) { + unawaited(_mainCameraController.closeCamera()); + } }); _selectNotificationSub = selectNotificationStream.stream.listen(( @@ -95,8 +104,8 @@ class HomeViewState extends State with WidgetsBindingObserver { }); if (initialPage == 1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_isViewActive()) { + Permission.camera.isGranted.then((hasPermission) { + if (hasPermission && mounted) { unawaited(_mainCameraController.selectCamera(0, true)); } }); @@ -104,20 +113,26 @@ class HomeViewState extends State with WidgetsBindingObserver { unawaited(_initAsync()); + void handleShareLink(Uri uri) { + routerProvider.go(Routes.home); + streamHomeViewPageIndex.add(1); + _mainCameraController.setSharedLinkForPreview(uri); + } + // Subscribe to all events (initial link and further) _deepLinkSub = AppLinks().uriLinkStream.listen((uri) async { if (!mounted) return; Log.info('Got link via app links: ${uri.scheme}'); if (!await handleIntentUrl(context, uri)) { if (uri.scheme.startsWith('http')) { - _mainCameraController.setSharedLinkForPreview(uri); + handleShareLink(uri); } } }); _intentStreamSub = initIntentStreams( context, - _mainCameraController.setSharedLinkForPreview, + handleShareLink, ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -126,6 +141,13 @@ class HomeViewState extends State with WidgetsBindingObserver { widget.initialPage == 0) { streamHomeViewPageIndex.add(0); } + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + setState(() { + _startPreloading = true; + }); + } + }); }); } @@ -156,11 +178,8 @@ class HomeViewState extends State with WidgetsBindingObserver { payload.startsWith(Routes.chats) && payload != Routes.chats) { routerProvider.go(payload); - streamHomeViewPageIndex.add(0); - } - if (payload == Routes.chats) { - streamHomeViewPageIndex.add(0); } + streamHomeViewPageIndex.add(0); } } @@ -202,9 +221,12 @@ class HomeViewState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { - if (_offsetRatio < 1 && + if (AppState.hasCameraPermissions && + _offsetRatio < 1 && !_mainCameraController.isSharePreviewIsShown && - _isViewActive()) { + _isViewActive() && + _mainCameraController.cameraController == null && + !_mainCameraController.initCameraStarted) { unawaited( _mainCameraController.selectCamera( _mainCameraController.selectedCameraDetails.cameraId, @@ -212,8 +234,7 @@ class HomeViewState extends State with WidgetsBindingObserver { ), ); } - } else if (state == AppLifecycleState.inactive || - state == AppLifecycleState.paused) { + } else if (state == AppLifecycleState.paused) { unawaited(_mainCameraController.closeCamera()); } } @@ -255,14 +276,19 @@ class HomeViewState extends State with WidgetsBindingObserver { } } - if (notification.depth == 0 && notification is ScrollUpdateNotification) { + if (notification.depth == 0) { setState(() { _offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0); _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 && _offsetRatio < 1 && _isViewActive()) { @@ -301,17 +327,21 @@ class HomeViewState extends State with WidgetsBindingObserver { NotificationListener( onNotification: _onPageView, child: Positioned.fill( - child: PageView( + child: CustomScrollView( + scrollDirection: Axis.horizontal, + physics: const PageScrollPhysics(), controller: _homeViewPageController, - onPageChanged: (index) { - setState(() { - _activePageIdx = index; - }); - }, - children: [ - const ChatListView(), - Container(), - const MemoriesView(), + scrollCacheExtent: _startPreloading + ? const ScrollCacheExtent.viewport(1) + : null, + slivers: [ + SliverFillViewport( + delegate: SliverChildListDelegate([ + const ChatListView(), + Container(), + const MemoriesView(), + ]), + ), ], ), ), diff --git a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart index 10e96fcb..599bc012 100644 --- a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart +++ b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart @@ -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/visual/components/selectable_thumbnail.comp.dart'; import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart'; + class MemoriesThumbnailComp extends StatefulWidget { const MemoriesThumbnailComp({ required this.galleryItem, @@ -28,14 +29,7 @@ class MemoriesThumbnailComp extends StatefulWidget { State createState() => _MemoriesThumbnailCompState(); } -final Set _alreadyAnimatedIds = {}; - -class _MemoriesThumbnailCompState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _scaleController; - late final Animation _scaleAnimation; - late final Animation _slideAnimation; - +class _MemoriesThumbnailCompState extends State { ImageProvider? _imageProvider; ImageStream? _imageStream; ImageInfo? _imageInfo; @@ -44,40 +38,6 @@ class _MemoriesThumbnailCompState extends State @override void initState() { super.initState(); - _scaleController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - ); - _scaleAnimation = Tween(begin: 0.94, end: 1).animate( - CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic), - ); - _slideAnimation = - Tween( - 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( (info, _) { @@ -101,8 +61,11 @@ class _MemoriesThumbnailCompState extends State void _resolveImage() { final media = widget.galleryItem.mediaService; - final hasThumbnail = media.thumbnailPath.existsSync() && media.thumbnailPath.lengthSync() > 0; - final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0; + final hasThumbnail = + media.thumbnailPath.existsSync() && + media.thumbnailPath.lengthSync() > 0; + final hasStored = + media.storedPath.existsSync() && media.storedPath.lengthSync() > 0; final isImageOrGif = media.mediaFile.type == MediaType.image || media.mediaFile.type == MediaType.gif; @@ -136,7 +99,6 @@ class _MemoriesThumbnailCompState extends State @override void dispose() { - _scaleController.dispose(); _imageStream?.removeListener(_listener); super.dispose(); } @@ -169,80 +131,71 @@ class _MemoriesThumbnailCompState extends State ); } : null, - child: SlideTransition( - position: _slideAnimation, - child: ScaleTransition( - scale: _scaleAnimation, - child: FadeTransition( - opacity: _scaleController, - child: SelectableThumbnailComp( - isSelected: widget.isSelected, - selectionMode: widget.selectionMode, - child: Stack( - fit: StackFit.expand, - children: [ - if (cachedInfo != null) - RawImage( - image: cachedInfo.image, - fit: BoxFit.cover, - ) - else if (_imageProvider != null) - Image( - image: _imageProvider!, - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (context, error, stackTrace) { - 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, - ), + child: SelectableThumbnailComp( + isSelected: widget.isSelected, + selectionMode: widget.selectionMode, + child: Stack( + fit: StackFit.expand, + children: [ + if (cachedInfo != null) + RawImage( + image: cachedInfo.image, + fit: BoxFit.cover, + ) + else if (_imageProvider != null) + Image( + image: _imageProvider!, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return ColoredBox( + color: Colors.grey.shade200, + child: const Center( + child: FaIcon( + FontAwesomeIcons.image, + color: Colors.black26, ), ), - 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), - ], - ), - ), - ], + ); + }, + ) + 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( + 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), + ], + ), + ), + ], ), ), ); diff --git a/lib/src/visual/views/memories/memories.view.dart b/lib/src/visual/views/memories/memories.view.dart index 0013d330..2ed4830b 100644 --- a/lib/src/visual/views/memories/memories.view.dart +++ b/lib/src/visual/views/memories/memories.view.dart @@ -21,7 +21,7 @@ class MemoriesView extends StatefulWidget { State createState() => MemoriesViewState(); } -class MemoriesViewState extends State { +class MemoriesViewState extends State with AutomaticKeepAliveClientMixin { late final MemoriesService _service; final ValueNotifier _activeMediaIdNotifier = ValueNotifier(null); final ScrollController _scrollController = ScrollController(); @@ -38,6 +38,9 @@ class MemoriesViewState extends State { _activeMediaIdNotifier.addListener(_onActiveMediaChanged); } + @override + bool get wantKeepAlive => true; + @override void dispose() { _activeMediaIdNotifier.removeListener(_onActiveMediaChanged); @@ -307,11 +310,18 @@ class MemoriesViewState extends State { if (item != null) { final media = item.mediaService; 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 || media.mediaFile.type == MediaType.gif) { 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); @@ -369,6 +379,7 @@ class MemoriesViewState extends State { @override Widget build(BuildContext context) { + super.build(context); return Scaffold( body: Stack( fit: StackFit.expand, diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 61ce30bb..52e108e1 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -193,11 +193,18 @@ class _SynchronizedImageViewerScreenState try { 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 || item.mediaFile.type == MediaType.gif) { 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; showSnackbar( diff --git a/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart b/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart index d2d9b96d..7b7b6848 100644 --- a/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart +++ b/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart @@ -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/services/android_photo_picker.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/visual/components/selectable_thumbnail.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; @@ -84,8 +85,8 @@ class _ImportFromGalleryViewState extends State { final hash = Uint8List.fromList(sha256.convert(bytes).bytes); - final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash); - if (exsits.isNotEmpty) { + final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash); + if (exists.isNotEmpty) { duplicated += 1; continue; } @@ -345,8 +346,8 @@ class _ImportFromGalleryViewState extends State { final hash = Uint8List.fromList(await sha256File(file)); - final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash); - if (exsits.isNotEmpty) { + final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash); + if (exists.isNotEmpty) { duplicated += 1; continue; } @@ -363,14 +364,50 @@ class _ImportFromGalleryViewState extends State { type = MediaType.image; } - final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia( - MediaFilesCompanion( - type: Value(type), - createdAt: Value(createdAt), - storedFileHash: Value(hash), - stored: const Value(true), - ), - ); + MediaFile? mediaFile; + var isRestored = false; + + if (Platform.isIOS) { + try { + 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) { final mediaService = MediaFileService(mediaFile); diff --git a/lib/src/visual/views/settings/help/changelog.view.dart b/lib/src/visual/views/settings/help/changelog.view.dart index c4f05891..f9870c0e 100644 --- a/lib/src/visual/views/settings/help/changelog.view.dart +++ b/lib/src/visual/views/settings/help/changelog.view.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; List parseMarkdown(BuildContext context, String markdown) { @@ -107,20 +106,6 @@ class _ChangeLogViewState extends State { ), ), ), - 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, - ), - ), - ], - ), - ), ); }, ); diff --git a/pubspec.yaml b/pubspec.yaml index aa60eba7..8db2d32a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.3.1+145 +version: 0.3.3+147 environment: sdk: ^3.11.0