diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 0778a72d..78f2d92f 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -141,7 +141,9 @@ class MediaFilesDao extends DatabaseAccessor Stream> watchAllStoredMediaFiles() { final query = (select(mediaFiles)..where((t) => t.stored.equals(true))).join([]) - ..groupBy([mediaFiles.storedFileHash]); + ..groupBy([ + const CustomExpression('COALESCE(stored_file_hash, media_id)') + ]); return query.map((row) => row.readTable(mediaFiles)).watch(); } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 189bf8f4..c95e98ca 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235 +Subproject commit c95e98ca929d630ead028d84e13934b30dbeba3b diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 83528eab..ba87a485 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -213,7 +213,12 @@ class MediaFileService { } Future createThumbnail() async { - if (!storedPath.existsSync()) { + if (!storedPath.existsSync() || storedPath.lengthSync() == 0) { + if (storedPath.existsSync() && storedPath.lengthSync() == 0) { + try { + storedPath.deleteSync(); + } catch (_) {} + } if (mediaFile.stored && mediaFile.createdAt.isBefore( clock.now().subtract(const Duration(days: 30)), @@ -288,8 +293,10 @@ class MediaFileService { bool get imagePreviewAvailable => mediaFile.hasThumbnail || - thumbnailPath.existsSync() || - storedPath.existsSync(); + (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) || + mediaFile.type == MediaType.audio || + ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && + storedPath.existsSync() && storedPath.lengthSync() > 0); Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); @@ -439,7 +446,7 @@ class MediaFileService { return; } - if (!storedPath.existsSync()) { + if (!storedPath.existsSync() || storedPath.lengthSync() == 0) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasCropAnalyzed: Value(true)), @@ -448,7 +455,7 @@ class MediaFileService { } try { - final bytes = storedPath.readAsBytesSync(); + final bytes = await storedPath.readAsBytes(); final result = await compute(_processImageCrop, bytes); if (result.isCropped && result.pngBytes != null) { @@ -460,18 +467,18 @@ class MediaFileService { ); if (webpBytes.isNotEmpty) { - storedPath.writeAsBytesSync(webpBytes); + await storedPath.writeAsBytes(webpBytes); } else { Log.warn('WebP compression returned empty, falling back to PNG'); - storedPath.writeAsBytesSync(result.pngBytes!); + await storedPath.writeAsBytes(result.pngBytes!); } } catch (e) { Log.error('Error compressing to WebP, falling back to PNG: $e'); - storedPath.writeAsBytesSync(result.pngBytes!); + await storedPath.writeAsBytes(result.pngBytes!); } if (thumbnailPath.existsSync()) { - thumbnailPath.deleteSync(); + await thumbnailPath.delete(); } await createThumbnail(); final checksum = await sha256File(storedPath); diff --git a/lib/src/services/mediafiles/thumbnail.service.dart b/lib/src/services/mediafiles/thumbnail.service.dart index 2e2209de..35fd86a6 100644 --- a/lib/src/services/mediafiles/thumbnail.service.dart +++ b/lib/src/services/mediafiles/thumbnail.service.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:image/image.dart' as img; import 'package:pro_video_editor/pro_video_editor.dart'; @@ -11,34 +12,61 @@ Future createThumbnailsForVideo( ) async { final stopwatch = Stopwatch()..start(); - if (destinationFile.existsSync()) { - return true; - } - - final images = await ProVideoEditor.instance.getThumbnails( - ThumbnailConfigs( - video: EditorVideo.file(sourceFile), - outputFormat: ThumbnailFormat.webp, - timestamps: const [ - Duration.zero, - ], - outputSize: const Size(272, 153), - ), - ); - - if (images.isNotEmpty) { - stopwatch.stop(); - destinationFile.writeAsBytesSync(images.first); - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.', - ); - return true; - } else { - Log.warn( - 'Thumbnail creation failed for the video.', - ); + if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { + Log.warn('Source video file does not exist or is empty.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} return false; } + + if (destinationFile.existsSync()) { + if (destinationFile.lengthSync() > 0) { + return true; + } else { + try { + destinationFile.deleteSync(); + } catch (_) {} + } + } + + try { + final images = await ProVideoEditor.instance.getThumbnails( + ThumbnailConfigs( + video: EditorVideo.file(sourceFile), + outputFormat: ThumbnailFormat.webp, + timestamps: const [ + Duration.zero, + ], + outputSize: const Size(272, 153), + ), + ); + + if (images.isNotEmpty && images.first.isNotEmpty) { + stopwatch.stop(); + await destinationFile.writeAsBytes(images.first); + if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { + Log.info( + 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.', + ); + return true; + } + } + } catch (e) { + Log.error('Error creating video thumbnail: $e'); + } + + Log.warn( + 'Thumbnail creation failed for the video.', + ); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; } Future createThumbnailsForImage( @@ -47,6 +75,26 @@ Future createThumbnailsForImage( ) async { final stopwatch = Stopwatch()..start(); + if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { + Log.warn('Source image file does not exist or is empty.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } + + if (destinationFile.existsSync()) { + if (destinationFile.lengthSync() > 0) { + return true; + } else { + try { + destinationFile.deleteSync(); + } catch (_) {} + } + } + try { await FlutterImageCompress.compressAndGetFile( sourceFile.absolute.path, @@ -57,12 +105,28 @@ Future createThumbnailsForImage( format: CompressFormat.webp, ); stopwatch.stop(); - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', - ); - return true; + + if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { + Log.info( + 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', + ); + return true; + } else { + Log.error('Compressed image thumbnail is empty or missing.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } } catch (e) { Log.error('Error creating image thumbnail: $e'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} return false; } } @@ -73,40 +137,81 @@ Future createThumbnailsForGif( ) async { final stopwatch = Stopwatch()..start(); + if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { + Log.warn('Source GIF file does not exist or is empty.'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } + if (destinationFile.existsSync()) { - return true; + if (destinationFile.lengthSync() > 0) { + return true; + } else { + try { + destinationFile.deleteSync(); + } catch (_) {} + } } try { // For GIFs, we decode the first frame and save it as WebP - final bytes = sourceFile.readAsBytesSync(); - final image = img.decodeGif(bytes); - if (image == null) { + final bytes = await sourceFile.readAsBytes(); + final pngBytes = await compute(_processGifThumbnail, bytes); + if (pngBytes == null || pngBytes.isEmpty) { Log.error('Could not decode GIF for thumbnail.'); return false; } - final thumbnail = img.copyResize( - image, - width: image.width > image.height ? 400 : null, - height: image.height >= image.width ? 400 : null, - ); - - final pngBytes = img.encodePng(thumbnail); final webp = await FlutterImageCompress.compressWithList( pngBytes, format: CompressFormat.webp, quality: 85, ); - destinationFile.writeAsBytesSync(webp); + if (webp.isEmpty) { + Log.error('GIF thumbnail compression returned empty.'); + return false; + } + + await destinationFile.writeAsBytes(webp); stopwatch.stop(); - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.', - ); - return true; + if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { + Log.info( + 'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.', + ); + return true; + } else { + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} + return false; + } } catch (e) { Log.error('Error creating GIF thumbnail: $e'); + try { + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + } catch (_) {} return false; } } + +Uint8List? _processGifThumbnail(Uint8List bytes) { + final image = img.decodeGif(bytes); + if (image == null) return null; + + final thumbnail = img.copyResize( + image, + width: image.width > image.height ? 400 : null, + height: image.height >= image.width ? 400 : null, + ); + + return img.encodePng(thumbnail); +} diff --git a/lib/src/services/memories/memories.service.dart b/lib/src/services/memories/memories.service.dart index b9d65939..4a532f70 100644 --- a/lib/src/services/memories/memories.service.dart +++ b/lib/src/services/memories/memories.service.dart @@ -200,6 +200,12 @@ class MemoriesService { Future _initAsync() async { try { + // Start DB subscription first so files with existing thumbnails are shown immediately. + await _dbSubscription?.cancel(); + _dbSubscription = twonlyDB.mediaFilesDao + .watchAllStoredMediaFiles() + .listen(_processMediaFilesStream); + final pendingFiles = await twonlyDB.mediaFilesDao .getAllMediaFilesPendingMigration(); @@ -210,23 +216,25 @@ class MemoriesService { ); _notifyState(); - for (final mediaFile in pendingFiles) { + // Run the multi-step background migration process asynchronously. + unawaited(_processMigrationQueue(pendingFiles)); + } + } catch (e) { + Log.error('Error initializing MemoriesService: $e'); + } + } + + Future _processMigrationQueue(List pendingFiles) async { + try { + // Phase 1: Create thumbnails first so files can be shown in the + // gallery immediately, without waiting for heavier operations. + for (final mediaFile in pendingFiles) { + try { final mediaService = MediaFileService(mediaFile); - if (mediaService.mediaFile.storedFileHash == null) { - await mediaService.hashMediaFile(); - } - - if (!mediaService.mediaFile.hasCropAnalyzed) { - await mediaService.cropTransparentBorders(); - } - - if (mediaService.mediaFile.sizeInBytes == null) { - await mediaService.calculateAndSaveSize(); - } - if (!mediaService.mediaFile.hasThumbnail) { - if (mediaService.thumbnailPath.existsSync()) { + if (mediaService.thumbnailPath.existsSync() && + mediaService.thumbnailPath.lengthSync() > 0) { await twonlyDB.mediaFilesDao.updateMedia( mediaFile.mediaId, const MediaFilesCompanion(hasThumbnail: Value(true)), @@ -235,18 +243,48 @@ class MemoriesService { await mediaService.createThumbnail(); } } - _updateMigrationCount(_currentState.filesToMigrate - 1); + } catch (e) { + Log.error( + 'Error creating thumbnail for ${mediaFile.mediaId}: $e', + ); } - - _updateMigrationCount(0); + _updateMigrationCount(_currentState.filesToMigrate - 1); } - await _dbSubscription?.cancel(); - _dbSubscription = twonlyDB.mediaFilesDao - .watchAllStoredMediaFiles() - .listen(_processMediaFilesStream); + _updateMigrationCount(0); + + // Phase 2: Background — hash, crop analysis, size calculation. + // Each DB write here fires the stream subscription above, keeping + // the gallery state fresh without a separate notification step. + await _backgroundProcessPendingFiles(pendingFiles); } catch (e) { - Log.error('Error initializing MemoriesService: $e'); + Log.error('Error in background migration queue: $e'); + } + } + + Future _backgroundProcessPendingFiles( + List pendingFiles, + ) async { + for (final mediaFile in pendingFiles) { + try { + final mediaService = MediaFileService(mediaFile); + + if (mediaService.mediaFile.storedFileHash == null) { + await mediaService.hashMediaFile(); + } + + if (!mediaService.mediaFile.hasCropAnalyzed) { + await mediaService.cropTransparentBorders(); + } + + if (mediaService.mediaFile.sizeInBytes == null) { + await mediaService.calculateAndSaveSize(); + } + } catch (e) { + Log.error( + 'Error in background processing of ${mediaFile.mediaId}: $e', + ); + } } } diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index 22e1b63e..3bbec5cc 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -7,6 +7,7 @@ enum MyButtonVariant { primary, secondary, text, + primaryMiddle, primaryDense, secondaryDense, } @@ -142,6 +143,23 @@ class _MyButtonState extends State borderRadius: BorderRadius.circular(18), ), ); + case MyButtonVariant.primaryMiddle: + buttonStyle = FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.black87, + 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.primaryDense: buttonStyle = FilledButton.styleFrom( backgroundColor: primaryColor, diff --git a/lib/src/visual/helpers/screenshot.helper.dart b/lib/src/visual/helpers/screenshot.helper.dart index 8605a38e..654de721 100644 --- a/lib/src/visual/helpers/screenshot.helper.dart +++ b/lib/src/visual/helpers/screenshot.helper.dart @@ -25,10 +25,20 @@ class ScreenshotImageHelper { return imageBytes; } if (imageBytesFuture != null) { - return imageBytesFuture; + try { + return imageBytes = await imageBytesFuture; + } catch (e) { + Log.error('Could not resolve imageBytesFuture: $e'); + return null; + } } if (file != null) { - return file!.readAsBytes(); + try { + return imageBytes = await file!.readAsBytes(); + } catch (e) { + Log.error('Could not read bytes from file: $e'); + return null; + } } if (image == null) return null; final img = await image!.toByteData(format: io.ImageByteFormat.png); @@ -61,7 +71,8 @@ class ScreenshotController { var tmpPixelRatio = pixelRatio; if (tmpPixelRatio == null) { if (context != null && context.mounted) { - tmpPixelRatio = tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio; + tmpPixelRatio = + tmpPixelRatio ?? MediaQuery.of(context).devicePixelRatio; } } final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1); 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 9be4b6aa..98d8778c 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 @@ -8,6 +8,7 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; class SaveToGalleryButton extends StatefulWidget { @@ -33,18 +34,11 @@ class SaveToGalleryButtonState extends State { @override Widget build(BuildContext context) { - return OutlinedButton( - style: OutlinedButton.styleFrom( - iconColor: _imageSaved - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.primary, - foregroundColor: _imageSaved - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.primary, - ), - onPressed: (widget.isLoading) - ? null - : () async { + final isEnabled = !widget.isLoading && !_imageSaving; + return MyButton( + variant: MyButtonVariant.secondaryDense, + onPressed: isEnabled + ? () async { setState(() { _imageSaving = true; }); @@ -83,19 +77,24 @@ class SaveToGalleryButtonState extends State { _imageSaving = false; }); } - }, + } + : null, child: Row( + mainAxisSize: MainAxisSize.min, children: [ if (_imageSaving || widget.isLoading) const SizedBox( width: 12, height: 12, - child: CircularProgressIndicator.adaptive(strokeWidth: 1), + child: CircularProgressIndicator.adaptive( + strokeWidth: 1, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), ) else _imageSaved - ? const Icon(Icons.check) - : const FaIcon(FontAwesomeIcons.floppyDisk), + ? const Icon(Icons.check, size: 14) + : const FaIcon(FontAwesomeIcons.floppyDisk, size: 14), if (widget.displayButtonLabel) const SizedBox(width: 10), if (widget.displayButtonLabel) Text( diff --git a/lib/src/visual/views/camera/share_image_contact_selection.view.dart b/lib/src/visual/views/camera/share_image_contact_selection.view.dart index 752157a1..dd890637 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection.view.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection.view.dart @@ -17,6 +17,7 @@ import 'package:twonly/src/visual/components/contact_request_badge.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/elements/headline.element.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.dart'; @@ -111,7 +112,9 @@ class _ShareImageView extends State { for (final group in groups) { if (group.pinned) continue; - if (!group.archived && getFlameCounterFromGroup(group).counter > 0 && bestFriends.length < 6) { + if (!group.archived && + getFlameCounterFromGroup(group).counter > 0 && + bestFriends.length < 6) { bestFriends.add(group); } else { otherUsers.add(group); @@ -131,7 +134,10 @@ class _ShareImageView extends State { await updateGroups( _allGroups .where( - (x) => !x.archived || !hideArchivedUsers || widget.selectedGroupIds.contains(x.groupId), + (x) => + !x.archived || + !hideArchivedUsers || + widget.selectedGroupIds.contains(x.groupId), ) .toList(), ); @@ -193,7 +199,8 @@ class _ShareImageView extends State { selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImagePinnedContacts, - showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, + showSelectAll: + !widget.mediaFileService.mediaFile.requiresAuthentication, ), const SizedBox(height: 10), BestFriendsSelector( @@ -201,7 +208,8 @@ class _ShareImageView extends State { selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImageBestFriends, - showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, + showSelectAll: + !widget.mediaFileService.mediaFile.requiresAuthentication, ), const SizedBox(height: 10), if (_otherUsers.isNotEmpty) @@ -264,7 +272,8 @@ class _ShareImageView extends State { child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (widget.mediaFileService.mediaFile.type == MediaType.image && + if (widget.mediaFileService.mediaFile.type == + MediaType.image && _screenshotImage?.image != null && userService.currentUser.showShowImagePreviewWhenSending) SizedBox( @@ -288,50 +297,53 @@ class _ShareImageView extends State { ), ), ), - FilledButton.icon( - icon: !mediaStoreFutureReady || sendingImage - ? SizedBox( - height: 12, - width: 12, + MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: + !mediaStoreFutureReady || + widget.selectedGroupIds.isEmpty || + sendingImage + ? null + : () async { + setState(() { + sendingImage = true; + }); + + // in case mediaStoreFutureReady is ready, the image is stored in the originalPath + await insertMediaFileInMessagesTable( + widget.mediaFileService, + widget.selectedGroupIds.toList(), + additionalData: widget.additionalData, + ); + + if (context.mounted) { + Navigator.pop(context, true); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!mediaStoreFutureReady || sendingImage) + const SizedBox( + height: 14, + width: 14, child: CircularProgressIndicator.adaptive( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), + valueColor: AlwaysStoppedAnimation( + Colors.black87, + ), ), ) - : const FaIcon(FontAwesomeIcons.solidPaperPlane), - onPressed: () async { - if (!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty) { - return; - } - - setState(() { - sendingImage = true; - }); - - // in case mediaStoreFutureReady is ready, the image is stored in the originalPath - await insertMediaFileInMessagesTable( - widget.mediaFileService, - widget.selectedGroupIds.toList(), - additionalData: widget.additionalData, - ); - - if (context.mounted) { - Navigator.pop(context, true); - } - }, - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(vertical: 10, horizontal: 30), - ), - backgroundColor: WidgetStateProperty.all( - !mediaStoreFutureReady || widget.selectedGroupIds.isEmpty - ? context.color.onSurface - : context.color.primary, - ), - ), - label: Text( - '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})', - style: const TextStyle(fontSize: 17), + else + const FaIcon( + FontAwesomeIcons.solidPaperPlane, + size: 14, + ), + const SizedBox(width: 8), + Text( + '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})', + ), + ], ), ), ], 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 923f157a..e3a8b436 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:typed_data'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; @@ -18,6 +19,7 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/emoji_picker.bottom.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; @@ -214,7 +216,8 @@ class _ShareImageEditorView extends State { List get actionsAtTheRight { if (layers.isNotEmpty && - (layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { + (layers.first.isEditing || + (layers.last.isEditing && layers.last.hasCustomActionButtons))) { return []; } return [ @@ -290,9 +293,13 @@ class _ShareImageEditorView extends State { if (media.type == MediaType.video) ...[ const SizedBox(height: 8), ActionButton( - (mediaService.removeAudio) ? Icons.volume_off_rounded : Icons.volume_up_rounded, + (mediaService.removeAudio) + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, tooltipText: 'Enable Audio in Video', - color: (mediaService.removeAudio) ? Colors.white.withAlpha(160) : Colors.white, + color: (mediaService.removeAudio) + ? Colors.white.withAlpha(160) + : Colors.white, onPressed: () async { await mediaService.toggleRemoveAudio(); if (mediaService.removeAudio) { @@ -330,7 +337,9 @@ class _ShareImageEditorView extends State { ActionButton( FontAwesomeIcons.shieldHeart, tooltipText: context.lang.protectAsARealTwonly, - color: media.requiresAuthentication ? Theme.of(context).colorScheme.primary : Colors.white, + color: media.requiresAuthentication + ? Theme.of(context).colorScheme.primary + : Colors.white, onPressed: () async { await mediaService.setRequiresAuth(!media.requiresAuthentication); selectedGroupIds = HashSet(); @@ -376,7 +385,8 @@ class _ShareImageEditorView extends State { List get actionsAtTheTop { if (layers.isNotEmpty && - (layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { + (layers.first.isEditing || + (layers.last.isEditing && layers.last.hasCustomActionButtons))) { return []; } return [ @@ -468,7 +478,8 @@ class _ShareImageEditorView extends State { } if (layers.length == 2) { final filterLayer = layers[1]; - if (layers.first is BackgroundLayerData && filterLayer is FilterLayerData) { + if (layers.first is BackgroundLayerData && + filterLayer is FilterLayerData) { if (filterLayer.page == 1) { return (layers.first as BackgroundLayerData).image.image; } @@ -501,6 +512,17 @@ class _ShareImageEditorView extends State { } Future storeImageAsOriginal() async { + Uint8List? gifBytes; + ScreenshotImageHelper? image; + if (media.type == MediaType.gif) { + gifBytes = await widget.screenshotImage?.getBytes(); + } else { + image = await getEditedImageBytes(); + if (image != null) { + await image.getBytes(); + } + } + if (mediaService.overlayImagePath.existsSync()) { mediaService.overlayImagePath.deleteSync(); } @@ -512,14 +534,12 @@ class _ShareImageEditorView extends State { mediaService.originalPath.deleteSync(); } } - ScreenshotImageHelper? image; + if (media.type == MediaType.gif) { - final bytes = await widget.screenshotImage?.getBytes(); - if (bytes != null) { - mediaService.originalPath.writeAsBytesSync(bytes.toList()); + if (gifBytes != null) { + mediaService.originalPath.writeAsBytesSync(gifBytes.toList()); } } else { - image = await getEditedImageBytes(); if (image == null) return null; final bytes = await image.getBytes(); if (bytes == null) { @@ -657,7 +677,9 @@ class _ShareImageEditorView extends State { await askToCloseThenClose(); }, child: Scaffold( - backgroundColor: widget.sharedFromGallery ? null : Colors.white.withAlpha(0), + backgroundColor: widget.sharedFromGallery + ? null + : Colors.white.withAlpha(0), resizeToAvoidBottomInset: false, body: Stack( fit: StackFit.expand, @@ -701,49 +723,57 @@ class _ShareImageEditorView extends State { ), if (widget.sendToGroup != null) const SizedBox(width: 10), if (widget.sendToGroup != null) - OutlinedButton( - style: OutlinedButton.styleFrom( - iconColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of( - context, - ).colorScheme.primary, - ), + MyButton( + variant: MyButtonVariant.secondaryDense, onPressed: pushShareImageView, - child: const FaIcon(FontAwesomeIcons.userPlus), + child: const FaIcon( + FontAwesomeIcons.userPlus, + size: 14, + ), ), SizedBox(width: widget.sendToGroup == null ? 20 : 10), - FilledButton.icon( - icon: sendingOrLoadingImage - ? SizedBox( - height: 12, - width: 12, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), + IntrinsicWidth( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: sendingOrLoadingImage + ? null + : () async { + if (widget.sendToGroup == null) { + return pushShareImageView(); + } + await sendImageToSinglePerson(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (sendingOrLoadingImage) + const SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.black87, + ), + ), + ) + else + const FaIcon( + FontAwesomeIcons.solidPaperPlane, + size: 14, ), - ) - : const FaIcon(FontAwesomeIcons.solidPaperPlane), - onPressed: () async { - if (sendingOrLoadingImage) return; - if (widget.sendToGroup == null) { - return pushShareImageView(); - } - await sendImageToSinglePerson(); - }, - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric( - vertical: 10, - horizontal: 30, - ), + const SizedBox(width: 8), + Text( + (widget.sendToGroup == null) + ? context.lang.shareImagedEditorShareWith + : substringBy( + widget.sendToGroup!.groupName, + 15, + ), + ), + ], ), ), - label: Text( - (widget.sendToGroup == null) - ? context.lang.shareImagedEditorShareWith - : substringBy(widget.sendToGroup!.groupName, 15), - style: const TextStyle(fontSize: 17), - ), ), ], ), diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 4feb177f..29ac3bf2 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -40,7 +40,10 @@ class _ChatListViewState extends State { bool _hasContacts = false; bool _loading = true; - bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty; + bool get _hasOpenGroup => + _groupsNotPinned.isNotEmpty || + _groupsArchived.isNotEmpty || + _groupsPinned.isNotEmpty; GlobalKey searchForOtherUsers = GlobalKey(); bool showFeedbackShortcut = false; @@ -64,35 +67,43 @@ class _ChatListViewState extends State { _contactsSub = stream.listen((groups) { if (!mounted) return; setState(() { - _groupsNotPinned = groups.where((x) => !x.pinned && !x.archived).toList(); + _groupsNotPinned = groups + .where((x) => !x.pinned && !x.archived) + .toList(); _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList(); _groupsArchived = groups.where((x) => x.archived).toList(); _loading = false; }); }); - _contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) { + _contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen(( + contacts, + ) { if (!mounted) return; setState(() { _hasContacts = contacts.isNotEmpty; }); }); - _countContactRequestStream = twonlyDB.contactsDao.watchContactsRequestedCount().listen((update) { - if (update != null) { - if (!mounted) return; - setState(() { - _countContactRequest = update; + _countContactRequestStream = twonlyDB.contactsDao + .watchContactsRequestedCount() + .listen((update) { + if (update != null) { + if (!mounted) return; + setState(() { + _countContactRequest = update; + }); + } }); - } - }); - _countAnnouncedStream = twonlyDB.userDiscoveryDao.watchNewAnnouncementsWithDataCount().listen((update) { - if (!mounted) return; - setState(() { - _countAnnouncedUsers = update; - }); - }); + _countAnnouncedStream = twonlyDB.userDiscoveryDao + .watchNewAnnouncementsWithDataCount() + .listen((update) { + if (!mounted) return; + setState(() { + _countAnnouncedUsers = update; + }); + }); WidgetsBinding.instance.addPostFrameCallback((_) async { final changeLog = await rootBundle.loadString('CHANGELOG.md'); @@ -101,7 +112,8 @@ class _ChatListViewState extends State { changeLog.codeUnits, )).bytes; if (!userService.currentUser.hideChangeLog && - userService.currentUser.lastChangeLogHash.toString() != changeLogHash.toString()) { + userService.currentUser.lastChangeLogHash.toString() != + changeLogHash.toString()) { await UserService.update((u) { u.lastChangeLogHash = changeLogHash; }); @@ -190,11 +202,16 @@ class _ChatListViewState extends State { ), Center( child: NotificationBadgeComp( - backgroundColor: isDarkMode(context) ? Colors.white : Colors.black, + backgroundColor: isDarkMode(context) + ? Colors.white + : Colors.black, textColor: isDarkMode(context) ? Colors.black : Colors.white, - count: (_countAnnouncedUsers + _countContactRequest).toString(), + count: (_countAnnouncedUsers + _countContactRequest) + .toString(), child: IconButton( - color: (_countAnnouncedUsers + _countContactRequest > 0) ? Colors.black : null, + color: (_countAnnouncedUsers + _countContactRequest > 0) + ? Colors.black + : null, key: searchForOtherUsers, icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), onPressed: () => context.push(Routes.chatsAddNewUser), @@ -240,7 +257,10 @@ class _ChatListViewState extends State { _groupsNotPinned.length + (_groupsArchived.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { - if (index >= _groupsNotPinned.length + _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0)) { + if (index >= + _groupsNotPinned.length + + _groupsPinned.length + + (_groupsPinned.isNotEmpty ? 1 : 0)) { if (_groupsArchived.isEmpty) return Container(); return ListTile( title: Text( @@ -304,7 +324,9 @@ class _ChatListViewState extends State { child: Center( child: FaIcon( FontAwesomeIcons.qrcode, - color: isDarkMode(context) ? Colors.black : Colors.white, + color: isDarkMode(context) + ? Colors.black + : Colors.white, ), ), ), diff --git a/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart b/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart index ce1686af..9330061e 100644 --- a/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart @@ -59,21 +59,23 @@ class EmptyChatListComp extends StatelessWidget { const SizedBox(height: 36), const Center(child: ProfileQrCodeComp()), const SizedBox(height: 36), - MyButton( - onPressed: () => _shareProfile(context), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const FaIcon(FontAwesomeIcons.shareNodes, size: 20), - const SizedBox(width: 8), - Text( - context.lang.emptyChatListShareBtn, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + IntrinsicWidth( + child: MyButton( + onPressed: () => _shareProfile(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon(FontAwesomeIcons.shareNodes, size: 20), + const SizedBox(width: 8), + Text( + context.lang.emptyChatListShareBtn, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - ], + ], + ), ), ), const SizedBox(height: 12), diff --git a/lib/src/visual/views/chats/chat_messages_components/response_container.dart b/lib/src/visual/views/chats/chat_messages_components/response_container.dart index d0d8d58d..9508dc7d 100644 --- a/lib/src/visual/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/visual/views/chats/chat_messages_components/response_container.dart @@ -40,8 +40,10 @@ class _ResponseContainerState extends State { void didChangeDependencies() { super.didChangeDependencies(); WidgetsBinding.instance.addPostFrameCallback((_) { - final messageBox = _message.currentContext?.findRenderObject() as RenderBox?; - final previewBox = _preview.currentContext?.findRenderObject() as RenderBox?; + final messageBox = + _message.currentContext?.findRenderObject() as RenderBox?; + final previewBox = + _preview.currentContext?.findRenderObject() as RenderBox?; if (messageBox == null || previewBox == null) { return; } @@ -64,7 +66,9 @@ class _ResponseContainerState extends State { return widget.child!; } return GestureDetector( - onTap: widget.scrollToMessage == null ? null : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), + onTap: widget.scrollToMessage == null + ? null + : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, @@ -140,12 +144,16 @@ class _ResponsePreviewState extends State { } Future initAsync() async { - _message ??= await twonlyDB.messagesDao.getMessageById(widget.messageId!).getSingleOrNull(); + _message ??= await twonlyDB.messagesDao + .getMessageById(widget.messageId!) + .getSingleOrNull(); if (_message?.mediaId != null) { _mediaService = await MediaFileService.fromMediaId(_message!.mediaId!); } if (_message?.senderId != null) { - final contact = await twonlyDB.contactsDao.getContactByUserId(_message!.senderId!).getSingleOrNull(); + final contact = await twonlyDB.contactsDao + .getContactByUserId(_message!.senderId!) + .getSingleOrNull(); if (contact != null) { _username = getContactDisplayName(contact); } @@ -263,15 +271,21 @@ class _ResponsePreviewState extends State { ], ), ), - if (_mediaService != null && _mediaService!.mediaFile.type != MediaType.audio) - SizedBox( - height: widget.showBorder ? 100 : 210, - child: Image.file( - _mediaService!.mediaFile.type == MediaType.video - ? _mediaService!.thumbnailPath - : _mediaService!.storedPath, - ), - ), + if (_mediaService != null && + _mediaService!.mediaFile.type != MediaType.audio) + () { + final isVideo = _mediaService!.mediaFile.type == MediaType.video; + final pathToCheck = isVideo + ? _mediaService!.thumbnailPath + : _mediaService!.storedPath; + if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) { + return SizedBox( + height: widget.showBorder ? 100 : 210, + child: Image.file(pathToCheck), + ); + } + return const SizedBox.shrink(); + }(), ], ), ); diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index eaea72cd..76aa2d6e 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -698,6 +698,15 @@ class _MediaViewerViewState extends State { ), 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/contact/add_new_contact_components/open_requests_list.comp.dart b/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart index 6f665b63..cc6fdfed 100644 --- a/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart +++ b/lib/src/visual/views/contact/add_new_contact_components/open_requests_list.comp.dart @@ -72,7 +72,10 @@ class OpenRequestsListComp extends StatelessWidget { if (block) { const update = ContactsCompanion(blocked: Value(true)); if (context.mounted) { - await twonlyDB.contactsDao.updateContact(contact.userId, update); + await twonlyDB.contactsDao.updateContact( + contact.userId, + update, + ); } } }, @@ -189,7 +192,9 @@ class OpenRequestsListComp extends StatelessWidget { ), trailing: Row( mainAxisSize: MainAxisSize.min, - children: contact.requested ? requestedActions(context, contact) : sendRequestActions(context, contact), + children: contact.requested + ? requestedActions(context, contact) + : sendRequestActions(context, contact), ), ); }), diff --git a/lib/src/visual/views/memories/components/flashback_banner.comp.dart b/lib/src/visual/views/memories/components/flashback_banner.comp.dart index df3fb0c4..2d5b8a54 100644 --- a/lib/src/visual/views/memories/components/flashback_banner.comp.dart +++ b/lib/src/visual/views/memories/components/flashback_banner.comp.dart @@ -66,6 +66,18 @@ class MemoriesFlashbackBannerComp extends StatelessWidget { Image.file( items.first.mediaService.storedPath, fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return ColoredBox( + color: Colors.grey.shade800, + child: const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.white30, + size: 32, + ), + ), + ); + }, ), Positioned.fill( child: DecoratedBox( 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 20f5a0de..10e96fcb 100644 --- a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart +++ b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart @@ -79,20 +79,30 @@ class _MemoriesThumbnailCompState extends State _scaleController.value = 1.0; } - _listener = ImageStreamListener((info, _) { - if (mounted) { - setState(() { - _imageInfo = info; - }); - } - }); + _listener = ImageStreamListener( + (info, _) { + if (mounted) { + setState(() { + _imageInfo = info; + }); + } + }, + onError: (exception, stackTrace) { + if (mounted) { + setState(() { + _imageProvider = null; + _imageInfo = null; + }); + } + }, + ); _resolveImage(); } void _resolveImage() { final media = widget.galleryItem.mediaService; - final hasThumbnail = media.thumbnailPath.existsSync(); - final hasStored = media.storedPath.existsSync(); + 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; @@ -181,6 +191,17 @@ class _MemoriesThumbnailCompState extends State 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( diff --git a/lib/src/visual/views/memories/memories.view.dart b/lib/src/visual/views/memories/memories.view.dart index 12e47292..0013d330 100644 --- a/lib/src/visual/views/memories/memories.view.dart +++ b/lib/src/visual/views/memories/memories.view.dart @@ -193,6 +193,63 @@ class MemoriesViewState extends State { }); } + Future _showProgressDialog( + String message, + Future Function(void Function(double progress) setProgress) task, + ) async { + final progressNotifier = ValueNotifier(0); + + // Show non-dismissible progress dialog + // ignore: unawaited_futures + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Text( + message, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 20), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (context, progress, _) { + return Column( + children: [ + LinearProgressIndicator(value: progress), + const SizedBox(height: 10), + Text( + '${(progress * 100).toInt()}%', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ), + ], + ), + ), + ); + }, + ); + + try { + await Future.delayed(Duration.zero); + await task((p) => progressNotifier.value = p); + } finally { + if (mounted) { + Navigator.of(context).pop(); + } + progressNotifier.dispose(); + } + } + Future _batchDelete() async { final count = _selectedMediaIds.length; final confirmed = await showAlertDialog( @@ -204,15 +261,24 @@ class MemoriesViewState extends State { if (!confirmed) return; final items = _service.currentState.galleryItems; - for (final mediaId in _selectedMediaIds) { - final item = items - .where((e) => e.mediaService.mediaFile.mediaId == mediaId) - .firstOrNull; - if (item != null) { - item.mediaService.fullMediaRemoval(); - } - await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); - } + final selectedList = _selectedMediaIds.toList(); + + await _showProgressDialog( + 'Deleting memories...', + (setProgress) async { + for (var i = 0; i < selectedList.length; i++) { + final mediaId = selectedList[i]; + final item = items + .where((e) => e.mediaService.mediaFile.mediaId == mediaId) + .firstOrNull; + if (item != null) { + item.mediaService.fullMediaRemoval(); + } + await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); + setProgress((i + 1) / selectedList.length); + } + }, + ); setState(_selectedMediaIds.clear); @@ -226,23 +292,34 @@ class MemoriesViewState extends State { Future _batchExport() async { final items = _service.currentState.galleryItems; + final selectedList = _selectedMediaIds.toList(); + if (selectedList.isEmpty) return; try { - for (final mediaId in _selectedMediaIds) { - final item = items - .where((e) => e.mediaService.mediaFile.mediaId == mediaId) - .firstOrNull; - if (item != null) { - final media = item.mediaService; - if (media.mediaFile.type == MediaType.video) { - await saveVideoToGallery(media.storedPath.path); - } 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 _showProgressDialog( + 'Exporting memories...', + (setProgress) async { + for (var i = 0; i < selectedList.length; i++) { + final mediaId = selectedList[i]; + final item = items + .where((e) => e.mediaService.mediaFile.mediaId == mediaId) + .firstOrNull; + if (item != null) { + final media = item.mediaService; + if (media.mediaFile.type == MediaType.video) { + await saveVideoToGallery(media.storedPath.path); + } 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); + } + } + setProgress((i + 1) / selectedList.length); } - } - } + }, + ); + + setState(_selectedMediaIds.clear); if (!mounted) return; showSnackbar( @@ -258,26 +335,36 @@ class MemoriesViewState extends State { Future _batchFavorite() async { final items = _service.currentState.galleryItems; + final selectedList = _selectedMediaIds.toList(); + if (selectedList.isEmpty) return; + var favCount = 0; for (final item in items) { - if (_selectedMediaIds.contains(item.mediaService.mediaFile.mediaId)) { + if (selectedList.contains(item.mediaService.mediaFile.mediaId)) { if (item.mediaService.mediaFile.isFavorite) { favCount++; } } } final areAllFav = - _selectedMediaIds.isNotEmpty && favCount == _selectedMediaIds.length; + selectedList.isNotEmpty && favCount == selectedList.length; final targetFav = !areAllFav; - for (final mediaId in _selectedMediaIds) { - await twonlyDB.mediaFilesDao.updateMedia( - mediaId, - MediaFilesCompanion(isFavorite: Value(targetFav)), - ); - } + await _showProgressDialog( + targetFav ? 'Adding to favorites...' : 'Removing from favorites...', + (setProgress) async { + for (var i = 0; i < selectedList.length; i++) { + final mediaId = selectedList[i]; + await twonlyDB.mediaFilesDao.updateMedia( + mediaId, + MediaFilesCompanion(isFavorite: Value(targetFav)), + ); + setProgress((i + 1) / selectedList.length); + } + }, + ); - setState(() {}); + setState(_selectedMediaIds.clear); } @override diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 19ec6f65..61ce30bb 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -347,6 +347,15 @@ class _SynchronizedImageViewerScreenState backgroundDecoration: const BoxDecoration( color: Colors.transparent, ), + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + Icons.broken_image_outlined, + color: Colors.white38, + size: 64, + ), + ); + }, scaleStateChangedCallback: (state) { final zoomed = state != PhotoViewScaleState.initial; diff --git a/lib/src/visual/views/onboarding/recover.view.dart b/lib/src/visual/views/onboarding/recover.view.dart index 0f172a35..f659c972 100644 --- a/lib/src/visual/views/onboarding/recover.view.dart +++ b/lib/src/visual/views/onboarding/recover.view.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; +import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; class BackupRecoveryView extends StatefulWidget { const BackupRecoveryView({super.key}); @@ -63,70 +64,6 @@ class _BackupRecoveryViewState extends State { }); } - void _showBackupExplanation(BuildContext context) { - final isDark = isDarkMode(context); - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; - final textColor = isDark ? Colors.white : Colors.black87; - final subtitleColor = isDark ? Colors.white70 : Colors.black54; - - showModalBottomSheet( - context: context, - backgroundColor: backgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(28), - ), - ), - isScrollControlled: true, - builder: (context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: isDark ? Colors.white24 : Colors.black12, - borderRadius: BorderRadius.circular(2.5), - ), - ), - ), - const SizedBox(height: 24), - Text( - 'twonly Backup', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: textColor, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - context.lang.backupTwonlySafeLongDesc, - style: TextStyle( - fontSize: 16, - height: 1.5, - color: subtitleColor, - ), - ), - const SizedBox(height: 32), - MyButton( - onPressed: () => Navigator.pop(context), - child: const Text('Got it'), - ), - ], - ), - ), - ); - }, - ); - } @override Widget build(BuildContext context) { @@ -164,7 +101,7 @@ class _BackupRecoveryViewState extends State { ), const Spacer(), IconButton( - onPressed: () => _showBackupExplanation(context), + onPressed: () => showBackupExplanation(context), icon: const FaIcon(FontAwesomeIcons.circleInfo), color: iconColor, iconSize: 20, diff --git a/lib/src/visual/views/settings/backup/backup_settings.view.dart b/lib/src/visual/views/settings/backup/backup_settings.view.dart index 2622345e..62eb9a96 100644 --- a/lib/src/visual/views/settings/backup/backup_settings.view.dart +++ b/lib/src/visual/views/settings/backup/backup_settings.view.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/model/json/backup.model.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class BackupView extends StatefulWidget { const BackupView({super.key}); @@ -176,7 +177,8 @@ class _BackupViewState extends State { ]), ), const SizedBox(height: 10), - OutlinedButton( + MyButton( + variant: MyButtonVariant.primaryMiddle, onPressed: _isLoading ? null : () async { @@ -194,7 +196,8 @@ class _BackupViewState extends State { ), const SizedBox(height: 32), Center( - child: FilledButton( + child: MyButton( + variant: MyButtonVariant.secondaryDense, onPressed: () => context.push(Routes.settingsBackupSetup, extra: true), child: Text( diff --git a/lib/src/visual/views/settings/backup/backup_setup.view.dart b/lib/src/visual/views/settings/backup/backup_setup.view.dart index 2eb9638c..5dab3c22 100644 --- a/lib/src/visual/views/settings/backup/backup_setup.view.dart +++ b/lib/src/visual/views/settings/backup/backup_setup.view.dart @@ -6,6 +6,7 @@ import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; class SetupBackupView extends StatefulWidget { @@ -76,13 +77,7 @@ class _SetupBackupViewState extends State { title: const Text('twonly Backup'), actions: [ IconButton( - onPressed: () async { - await showAlertDialog( - context, - 'twonly Backup', - context.lang.backupTwonlySafeLongDesc, - ); - }, + onPressed: () => showBackupExplanation(context), icon: const FaIcon(FontAwesomeIcons.circleInfo), iconSize: 18, ), @@ -131,7 +126,8 @@ class _SetupBackupViewState extends State { ), const SizedBox(height: 10), Center( - child: FilledButton.icon( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, onPressed: (!_isLoading && (_passwordController.text == @@ -140,17 +136,26 @@ class _SetupBackupViewState extends State { !kReleaseMode)) ? _updateBackupPassword : null, - icon: _isLoading - ? const SizedBox( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isLoading) + const SizedBox( height: 12, width: 12, - child: CircularProgressIndicator.adaptive(strokeWidth: 1), + child: CircularProgressIndicator.adaptive( + strokeWidth: 1, + ), ) - : const Icon(Icons.lock_clock_rounded), - label: Text( - userService.currentUser.isBackupEnabled - ? context.lang.backupEnableBackup - : context.lang.backupChangePassword, + else + const Icon(Icons.lock_clock_rounded), + const SizedBox(width: 8), + Text( + userService.currentUser.isBackupEnabled + ? context.lang.backupEnableBackup + : context.lang.backupChangePassword, + ), + ], ), ), ), diff --git a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart index cb553ead..c5fcc189 100644 --- a/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart +++ b/lib/src/visual/views/settings/backup/components/backup_setup.comp.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; Future isSecurePassword(String password) async { @@ -90,3 +92,68 @@ class PasswordRequirementText extends StatelessWidget { ); } } + +void showBackupExplanation(BuildContext context) { + final isDark = isDarkMode(context); + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + final textColor = isDark ? Colors.white : Colors.black87; + final subtitleColor = isDark ? Colors.white70 : Colors.black54; + + showModalBottomSheet( + context: context, + backgroundColor: backgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(28), + ), + ), + isScrollControlled: true, + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.black12, + borderRadius: BorderRadius.circular(2.5), + ), + ), + ), + const SizedBox(height: 24), + Text( + 'twonly Backup', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + context.lang.backupTwonlySafeLongDesc, + style: TextStyle( + fontSize: 16, + height: 1.5, + color: subtitleColor, + ), + ), + const SizedBox(height: 32), + MyButton( + onPressed: () => Navigator.pop(context), + child: const Text('Got it'), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/src/visual/views/settings/help/contact_us.view.dart b/lib/src/visual/views/settings/help/contact_us.view.dart index bbaeb18d..1727c4da 100644 --- a/lib/src/visual/views/settings/help/contact_us.view.dart +++ b/lib/src/visual/views/settings/help/contact_us.view.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; @@ -12,6 +15,7 @@ import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/settings/help/contact_us/submit_message.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; @@ -29,13 +33,29 @@ class _ContactUsState extends State { int? _selectedFeedback; String? _selectedReason; String? debugLogDownloadToken; + String? debugLogEncryptionKey; - Future uploadDebugLog() async { - if (debugLogDownloadToken != null) return debugLogDownloadToken; + Future<(String, String)?> uploadDebugLog() async { + if (debugLogDownloadToken != null && debugLogEncryptionKey != null) { + return (debugLogDownloadToken!, debugLogEncryptionKey!); + } final downloadToken = getRandomUint8List(32); + final encryptionKey = getRandomUint8List(32); final debugLog = await loadLogFile(); + // 1. Compress the debug log + final logBytes = utf8.encode(debugLog); + final compressedBytes = gzip.encode(logBytes); + + // 2. Encrypt using AES-GCM (with 256 bits) + final algorithm = AesGcm.with256bits(); + final secretBox = await algorithm.encrypt( + compressedBytes, + secretKey: SecretKey(encryptionKey), + ); + final encryptedData = secretBox.concatenation(); + final messageOnSuccess = TextMessage() ..body = [] ..userId = Int64(); @@ -43,7 +63,7 @@ class _ContactUsState extends State { final uploadRequest = UploadRequest( messagesOnSuccess: [messageOnSuccess], downloadTokens: [downloadToken], - encryptedData: debugLog.codeUnits, + encryptedData: encryptedData, ); final uploadRequestBytes = uploadRequest.writeToBuffer(); @@ -71,10 +91,13 @@ class _ContactUsState extends State { final response = await requestMultipart.send(); if (response.statusCode == 200) { + final tokenHex = uint8ListToHex(downloadToken); + final keyHex = uint8ListToHex(encryptionKey); setState(() { - debugLogDownloadToken = uint8ListToHex(downloadToken); + debugLogDownloadToken = tokenHex; + debugLogEncryptionKey = keyHex; }); - return debugLogDownloadToken; + return (tokenHex, keyHex); } return null; } @@ -108,13 +131,13 @@ class _ContactUsState extends State { } if (includeDebugLog) { - String? token; + (String, String)? result; try { - token = await uploadDebugLog(); + result = await uploadDebugLog(); } catch (e) { Log.error(e); } - if (token == null) { + if (result == null) { if (!mounted) return null; showSnackbar(context, 'Could not upload the debug log!'); setState(() { @@ -122,7 +145,10 @@ class _ContactUsState extends State { }); return null; } - debugLogToken = 'Debug Log: https://api.twonly.eu/api/download/$token'; + final downloadToken = result.$1; + final encryptionKey = result.$2; + debugLogToken = + 'Debug Log: https://logs.twonly.eu#$downloadToken/$encryptionKey'; } setState(() { @@ -238,17 +264,8 @@ $debugLogToken ), ), ), - ElevatedButton.icon( - icon: isLoading - ? SizedBox( - height: 12, - width: 12, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), - ), - ) - : const FaIcon(FontAwesomeIcons.angleRight), + MyButton( + variant: MyButtonVariant.primaryDense, onPressed: isLoading ? null : () async { @@ -263,7 +280,24 @@ $debugLogToken Navigator.pop(context); } }, - label: Text(context.lang.next), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + const SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.black87), + ), + ) + else + const FaIcon(FontAwesomeIcons.angleRight, size: 14), + const SizedBox(width: 8), + Text(context.lang.next), + ], + ), ), ], ), diff --git a/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart b/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart index 104cc236..3846c486 100644 --- a/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart +++ b/lib/src/visual/views/settings/help/contact_us/submit_message.view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class SubmitMessage extends StatefulWidget { const SubmitMessage({required this.fullMessage, super.key}); @@ -100,7 +101,8 @@ class _ContactUsState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( + MyButton( + variant: MyButtonVariant.primaryDense, onPressed: isLoading ? null : _submitFeedback, child: Text(context.lang.submit), ), diff --git a/test.jpg b/test.jpg deleted file mode 100644 index c91ed5da..00000000 Binary files a/test.jpg and /dev/null differ