replacing more buttons
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-05 02:39:56 +02:00
parent e2cf5ec74a
commit 12dce4f52d
26 changed files with 841 additions and 390 deletions

View file

@ -141,7 +141,9 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
Stream<List<MediaFile>> watchAllStoredMediaFiles() { Stream<List<MediaFile>> watchAllStoredMediaFiles() {
final query = final query =
(select(mediaFiles)..where((t) => t.stored.equals(true))).join([]) (select(mediaFiles)..where((t) => t.stored.equals(true))).join([])
..groupBy([mediaFiles.storedFileHash]); ..groupBy([
const CustomExpression<Object>('COALESCE(stored_file_hash, media_id)')
]);
return query.map((row) => row.readTable(mediaFiles)).watch(); return query.map((row) => row.readTable(mediaFiles)).watch();
} }

@ -1 +1 @@
Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235 Subproject commit c95e98ca929d630ead028d84e13934b30dbeba3b

View file

@ -213,7 +213,12 @@ class MediaFileService {
} }
Future<void> createThumbnail() async { Future<void> createThumbnail() async {
if (!storedPath.existsSync()) { if (!storedPath.existsSync() || storedPath.lengthSync() == 0) {
if (storedPath.existsSync() && storedPath.lengthSync() == 0) {
try {
storedPath.deleteSync();
} catch (_) {}
}
if (mediaFile.stored && if (mediaFile.stored &&
mediaFile.createdAt.isBefore( mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(days: 30)), clock.now().subtract(const Duration(days: 30)),
@ -288,8 +293,10 @@ class MediaFileService {
bool get imagePreviewAvailable => bool get imagePreviewAvailable =>
mediaFile.hasThumbnail || mediaFile.hasThumbnail ||
thumbnailPath.existsSync() || (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) ||
storedPath.existsSync(); mediaFile.type == MediaType.audio ||
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
storedPath.existsSync() && storedPath.lengthSync() > 0);
Future<void> storeMediaFile() async { Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}'); Log.info('Storing media file ${mediaFile.mediaId}');
@ -439,7 +446,7 @@ class MediaFileService {
return; return;
} }
if (!storedPath.existsSync()) { if (!storedPath.existsSync() || storedPath.lengthSync() == 0) {
await twonlyDB.mediaFilesDao.updateMedia( await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId, mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)), const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
@ -448,7 +455,7 @@ class MediaFileService {
} }
try { try {
final bytes = storedPath.readAsBytesSync(); final bytes = await storedPath.readAsBytes();
final result = await compute(_processImageCrop, bytes); final result = await compute(_processImageCrop, bytes);
if (result.isCropped && result.pngBytes != null) { if (result.isCropped && result.pngBytes != null) {
@ -460,18 +467,18 @@ class MediaFileService {
); );
if (webpBytes.isNotEmpty) { if (webpBytes.isNotEmpty) {
storedPath.writeAsBytesSync(webpBytes); await storedPath.writeAsBytes(webpBytes);
} else { } else {
Log.warn('WebP compression returned empty, falling back to PNG'); Log.warn('WebP compression returned empty, falling back to PNG');
storedPath.writeAsBytesSync(result.pngBytes!); await storedPath.writeAsBytes(result.pngBytes!);
} }
} catch (e) { } catch (e) {
Log.error('Error compressing to WebP, falling back to PNG: $e'); Log.error('Error compressing to WebP, falling back to PNG: $e');
storedPath.writeAsBytesSync(result.pngBytes!); await storedPath.writeAsBytes(result.pngBytes!);
} }
if (thumbnailPath.existsSync()) { if (thumbnailPath.existsSync()) {
thumbnailPath.deleteSync(); await thumbnailPath.delete();
} }
await createThumbnail(); await createThumbnail();
final checksum = await sha256File(storedPath); final checksum = await sha256File(storedPath);

View file

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:pro_video_editor/pro_video_editor.dart'; import 'package:pro_video_editor/pro_video_editor.dart';
@ -11,10 +12,27 @@ Future<bool> createThumbnailsForVideo(
) async { ) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
Log.warn('Source video file does not exist or is empty.');
try {
if (destinationFile.existsSync()) { if (destinationFile.existsSync()) {
return true; 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( final images = await ProVideoEditor.instance.getThumbnails(
ThumbnailConfigs( ThumbnailConfigs(
video: EditorVideo.file(sourceFile), video: EditorVideo.file(sourceFile),
@ -26,19 +44,29 @@ Future<bool> createThumbnailsForVideo(
), ),
); );
if (images.isNotEmpty) { if (images.isNotEmpty && images.first.isNotEmpty) {
stopwatch.stop(); stopwatch.stop();
destinationFile.writeAsBytesSync(images.first); await destinationFile.writeAsBytes(images.first);
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
Log.info( Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.', 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.',
); );
return true; return true;
} else { }
}
} catch (e) {
Log.error('Error creating video thumbnail: $e');
}
Log.warn( Log.warn(
'Thumbnail creation failed for the video.', 'Thumbnail creation failed for the video.',
); );
return false; try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
} }
} catch (_) {}
return false;
} }
Future<bool> createThumbnailsForImage( Future<bool> createThumbnailsForImage(
@ -47,6 +75,26 @@ Future<bool> createThumbnailsForImage(
) async { ) async {
final stopwatch = Stopwatch()..start(); 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 { try {
await FlutterImageCompress.compressAndGetFile( await FlutterImageCompress.compressAndGetFile(
sourceFile.absolute.path, sourceFile.absolute.path,
@ -57,12 +105,28 @@ Future<bool> createThumbnailsForImage(
format: CompressFormat.webp, format: CompressFormat.webp,
); );
stopwatch.stop(); stopwatch.stop();
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
Log.info( Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.',
); );
return true; return true;
} else {
Log.error('Compressed image thumbnail is empty or missing.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
} catch (e) { } catch (e) {
Log.error('Error creating image thumbnail: $e'); Log.error('Error creating image thumbnail: $e');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false; return false;
} }
} }
@ -73,40 +137,81 @@ Future<bool> createThumbnailsForGif(
) async { ) async {
final stopwatch = Stopwatch()..start(); 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()) { if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
if (destinationFile.existsSync()) {
if (destinationFile.lengthSync() > 0) {
return true; return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
} }
try { try {
// For GIFs, we decode the first frame and save it as WebP // For GIFs, we decode the first frame and save it as WebP
final bytes = sourceFile.readAsBytesSync(); final bytes = await sourceFile.readAsBytes();
final image = img.decodeGif(bytes); final pngBytes = await compute(_processGifThumbnail, bytes);
if (image == null) { if (pngBytes == null || pngBytes.isEmpty) {
Log.error('Could not decode GIF for thumbnail.'); Log.error('Could not decode GIF for thumbnail.');
return false; return false;
} }
final webp = await FlutterImageCompress.compressWithList(
pngBytes,
format: CompressFormat.webp,
quality: 85,
);
if (webp.isEmpty) {
Log.error('GIF thumbnail compression returned empty.');
return false;
}
await destinationFile.writeAsBytes(webp);
stopwatch.stop();
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( final thumbnail = img.copyResize(
image, image,
width: image.width > image.height ? 400 : null, width: image.width > image.height ? 400 : null,
height: image.height >= image.width ? 400 : null, height: image.height >= image.width ? 400 : null,
); );
final pngBytes = img.encodePng(thumbnail); return img.encodePng(thumbnail);
final webp = await FlutterImageCompress.compressWithList(
pngBytes,
format: CompressFormat.webp,
quality: 85,
);
destinationFile.writeAsBytesSync(webp);
stopwatch.stop();
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
);
return true;
} catch (e) {
Log.error('Error creating GIF thumbnail: $e');
return false;
}
} }

View file

@ -200,6 +200,12 @@ class MemoriesService {
Future<void> _initAsync() async { Future<void> _initAsync() async {
try { 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 final pendingFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingMigration(); .getAllMediaFilesPendingMigration();
@ -210,7 +216,57 @@ class MemoriesService {
); );
_notifyState(); _notifyState();
// Run the multi-step background migration process asynchronously.
unawaited(_processMigrationQueue(pendingFiles));
}
} catch (e) {
Log.error('Error initializing MemoriesService: $e');
}
}
Future<void> _processMigrationQueue(List<MediaFile> 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) { for (final mediaFile in pendingFiles) {
try {
final mediaService = MediaFileService(mediaFile);
if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync() &&
mediaService.thumbnailPath.lengthSync() > 0) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
} else if (mediaFile.type != MediaType.audio) {
await mediaService.createThumbnail();
}
}
} catch (e) {
Log.error(
'Error creating thumbnail for ${mediaFile.mediaId}: $e',
);
}
_updateMigrationCount(_currentState.filesToMigrate - 1);
}
_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 in background migration queue: $e');
}
}
Future<void> _backgroundProcessPendingFiles(
List<MediaFile> pendingFiles,
) async {
for (final mediaFile in pendingFiles) {
try {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
if (mediaService.mediaFile.storedFileHash == null) { if (mediaService.mediaFile.storedFileHash == null) {
@ -224,29 +280,11 @@ class MemoriesService {
if (mediaService.mediaFile.sizeInBytes == null) { if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize(); await mediaService.calculateAndSaveSize();
} }
if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync()) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
} else if (mediaFile.type != MediaType.audio) {
await mediaService.createThumbnail();
}
}
_updateMigrationCount(_currentState.filesToMigrate - 1);
}
_updateMigrationCount(0);
}
await _dbSubscription?.cancel();
_dbSubscription = twonlyDB.mediaFilesDao
.watchAllStoredMediaFiles()
.listen(_processMediaFilesStream);
} catch (e) { } catch (e) {
Log.error('Error initializing MemoriesService: $e'); Log.error(
'Error in background processing of ${mediaFile.mediaId}: $e',
);
}
} }
} }

View file

@ -7,6 +7,7 @@ enum MyButtonVariant {
primary, primary,
secondary, secondary,
text, text,
primaryMiddle,
primaryDense, primaryDense,
secondaryDense, secondaryDense,
} }
@ -142,6 +143,23 @@ class _MyButtonState extends State<MyButton>
borderRadius: BorderRadius.circular(18), 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: case MyButtonVariant.primaryDense:
buttonStyle = FilledButton.styleFrom( buttonStyle = FilledButton.styleFrom(
backgroundColor: primaryColor, backgroundColor: primaryColor,

View file

@ -25,10 +25,20 @@ class ScreenshotImageHelper {
return imageBytes; return imageBytes;
} }
if (imageBytesFuture != null) { if (imageBytesFuture != null) {
return imageBytesFuture; try {
return imageBytes = await imageBytesFuture;
} catch (e) {
Log.error('Could not resolve imageBytesFuture: $e');
return null;
}
} }
if (file != 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; if (image == null) return null;
final img = await image!.toByteData(format: io.ImageByteFormat.png); final img = await image!.toByteData(format: io.ImageByteFormat.png);
@ -61,7 +71,8 @@ class ScreenshotController {
var tmpPixelRatio = pixelRatio; var tmpPixelRatio = pixelRatio;
if (tmpPixelRatio == null) { if (tmpPixelRatio == null) {
if (context != null && context.mounted) { 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); final image = await boundary.toImage(pixelRatio: tmpPixelRatio ?? 1);

View file

@ -8,6 +8,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
class SaveToGalleryButton extends StatefulWidget { class SaveToGalleryButton extends StatefulWidget {
@ -33,18 +34,11 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( final isEnabled = !widget.isLoading && !_imageSaving;
style: OutlinedButton.styleFrom( return MyButton(
iconColor: _imageSaved variant: MyButtonVariant.secondaryDense,
? Theme.of(context).colorScheme.outline onPressed: isEnabled
: Theme.of(context).colorScheme.primary, ? () async {
foregroundColor: _imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
onPressed: (widget.isLoading)
? null
: () async {
setState(() { setState(() {
_imageSaving = true; _imageSaving = true;
}); });
@ -83,19 +77,24 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
_imageSaving = false; _imageSaving = false;
}); });
} }
}, }
: null,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_imageSaving || widget.isLoading) if (_imageSaving || widget.isLoading)
const SizedBox( const SizedBox(
width: 12, width: 12,
height: 12, height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1), child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
) )
else else
_imageSaved _imageSaved
? const Icon(Icons.check) ? const Icon(Icons.check, size: 14)
: const FaIcon(FontAwesomeIcons.floppyDisk), : const FaIcon(FontAwesomeIcons.floppyDisk, size: 14),
if (widget.displayButtonLabel) const SizedBox(width: 10), if (widget.displayButtonLabel) const SizedBox(width: 10),
if (widget.displayButtonLabel) if (widget.displayButtonLabel)
Text( Text(

View file

@ -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/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.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/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/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/best_friends_selector.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/shortcut_row.comp.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<ShareImageView> {
for (final group in groups) { for (final group in groups) {
if (group.pinned) continue; 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); bestFriends.add(group);
} else { } else {
otherUsers.add(group); otherUsers.add(group);
@ -131,7 +134,10 @@ class _ShareImageView extends State<ShareImageView> {
await updateGroups( await updateGroups(
_allGroups _allGroups
.where( .where(
(x) => !x.archived || !hideArchivedUsers || widget.selectedGroupIds.contains(x.groupId), (x) =>
!x.archived ||
!hideArchivedUsers ||
widget.selectedGroupIds.contains(x.groupId),
) )
.toList(), .toList(),
); );
@ -193,7 +199,8 @@ class _ShareImageView extends State<ShareImageView> {
selectedGroupIds: widget.selectedGroupIds, selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImagePinnedContacts, title: context.lang.shareImagePinnedContacts,
showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, showSelectAll:
!widget.mediaFileService.mediaFile.requiresAuthentication,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
BestFriendsSelector( BestFriendsSelector(
@ -201,7 +208,8 @@ class _ShareImageView extends State<ShareImageView> {
selectedGroupIds: widget.selectedGroupIds, selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImageBestFriends, title: context.lang.shareImageBestFriends,
showSelectAll: !widget.mediaFileService.mediaFile.requiresAuthentication, showSelectAll:
!widget.mediaFileService.mediaFile.requiresAuthentication,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
if (_otherUsers.isNotEmpty) if (_otherUsers.isNotEmpty)
@ -264,7 +272,8 @@ class _ShareImageView extends State<ShareImageView> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (widget.mediaFileService.mediaFile.type == MediaType.image && if (widget.mediaFileService.mediaFile.type ==
MediaType.image &&
_screenshotImage?.image != null && _screenshotImage?.image != null &&
userService.currentUser.showShowImagePreviewWhenSending) userService.currentUser.showShowImagePreviewWhenSending)
SizedBox( SizedBox(
@ -288,22 +297,14 @@ class _ShareImageView extends State<ShareImageView> {
), ),
), ),
), ),
FilledButton.icon( MyButton(
icon: !mediaStoreFutureReady || sendingImage variant: MyButtonVariant.primaryMiddle,
? SizedBox( onPressed:
height: 12, !mediaStoreFutureReady ||
width: 12, widget.selectedGroupIds.isEmpty ||
child: CircularProgressIndicator.adaptive( sendingImage
strokeWidth: 2, ? null
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary), : () async {
),
)
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async {
if (!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty) {
return;
}
setState(() { setState(() {
sendingImage = true; sendingImage = true;
}); });
@ -319,19 +320,30 @@ class _ShareImageView extends State<ShareImageView> {
Navigator.pop(context, true); Navigator.pop(context, true);
} }
}, },
style: ButtonStyle( child: Row(
padding: WidgetStateProperty.all<EdgeInsets>( mainAxisSize: MainAxisSize.min,
const EdgeInsets.symmetric(vertical: 10, horizontal: 30), children: [
), if (!mediaStoreFutureReady || sendingImage)
backgroundColor: WidgetStateProperty.all<Color>( const SizedBox(
!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty height: 14,
? context.color.onSurface width: 14,
: context.color.primary, child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
Colors.black87,
), ),
), ),
label: Text( )
else
const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 14,
),
const SizedBox(width: 8),
Text(
'${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})', '${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})',
style: const TextStyle(fontSize: 17), ),
],
), ),
), ),
], ],

View file

@ -2,6 +2,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:typed_data';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; 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/utils/misc.dart';
import 'package:twonly/src/visual/components/emoji_picker.bottom.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/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/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/helpers/screenshot.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'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
@ -214,7 +216,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheRight { List<Widget> get actionsAtTheRight {
if (layers.isNotEmpty && if (layers.isNotEmpty &&
(layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { (layers.first.isEditing ||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return []; return [];
} }
return <Widget>[ return <Widget>[
@ -290,9 +293,13 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (media.type == MediaType.video) ...[ if (media.type == MediaType.video) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
ActionButton( 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', 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 { onPressed: () async {
await mediaService.toggleRemoveAudio(); await mediaService.toggleRemoveAudio();
if (mediaService.removeAudio) { if (mediaService.removeAudio) {
@ -330,7 +337,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
ActionButton( ActionButton(
FontAwesomeIcons.shieldHeart, FontAwesomeIcons.shieldHeart,
tooltipText: context.lang.protectAsARealTwonly, 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 { onPressed: () async {
await mediaService.setRequiresAuth(!media.requiresAuthentication); await mediaService.setRequiresAuth(!media.requiresAuthentication);
selectedGroupIds = HashSet(); selectedGroupIds = HashSet();
@ -376,7 +385,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheTop { List<Widget> get actionsAtTheTop {
if (layers.isNotEmpty && if (layers.isNotEmpty &&
(layers.first.isEditing || (layers.last.isEditing && layers.last.hasCustomActionButtons))) { (layers.first.isEditing ||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return []; return [];
} }
return [ return [
@ -468,7 +478,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
} }
if (layers.length == 2) { if (layers.length == 2) {
final filterLayer = layers[1]; final filterLayer = layers[1];
if (layers.first is BackgroundLayerData && filterLayer is FilterLayerData) { if (layers.first is BackgroundLayerData &&
filterLayer is FilterLayerData) {
if (filterLayer.page == 1) { if (filterLayer.page == 1) {
return (layers.first as BackgroundLayerData).image.image; return (layers.first as BackgroundLayerData).image.image;
} }
@ -501,6 +512,17 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
} }
Future<ScreenshotImageHelper?> storeImageAsOriginal() async { Future<ScreenshotImageHelper?> 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()) { if (mediaService.overlayImagePath.existsSync()) {
mediaService.overlayImagePath.deleteSync(); mediaService.overlayImagePath.deleteSync();
} }
@ -512,14 +534,12 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
mediaService.originalPath.deleteSync(); mediaService.originalPath.deleteSync();
} }
} }
ScreenshotImageHelper? image;
if (media.type == MediaType.gif) { if (media.type == MediaType.gif) {
final bytes = await widget.screenshotImage?.getBytes(); if (gifBytes != null) {
if (bytes != null) { mediaService.originalPath.writeAsBytesSync(gifBytes.toList());
mediaService.originalPath.writeAsBytesSync(bytes.toList());
} }
} else { } else {
image = await getEditedImageBytes();
if (image == null) return null; if (image == null) return null;
final bytes = await image.getBytes(); final bytes = await image.getBytes();
if (bytes == null) { if (bytes == null) {
@ -657,7 +677,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await askToCloseThenClose(); await askToCloseThenClose();
}, },
child: Scaffold( child: Scaffold(
backgroundColor: widget.sharedFromGallery ? null : Colors.white.withAlpha(0), backgroundColor: widget.sharedFromGallery
? null
: Colors.white.withAlpha(0),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -701,48 +723,56 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
), ),
if (widget.sendToGroup != null) const SizedBox(width: 10), if (widget.sendToGroup != null) const SizedBox(width: 10),
if (widget.sendToGroup != null) if (widget.sendToGroup != null)
OutlinedButton( MyButton(
style: OutlinedButton.styleFrom( variant: MyButtonVariant.secondaryDense,
iconColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.primary,
),
onPressed: pushShareImageView, onPressed: pushShareImageView,
child: const FaIcon(FontAwesomeIcons.userPlus), child: const FaIcon(
FontAwesomeIcons.userPlus,
size: 14,
),
), ),
SizedBox(width: widget.sendToGroup == null ? 20 : 10), SizedBox(width: widget.sendToGroup == null ? 20 : 10),
FilledButton.icon( IntrinsicWidth(
icon: sendingOrLoadingImage child: MyButton(
? SizedBox( variant: MyButtonVariant.primaryMiddle,
height: 12, onPressed: sendingOrLoadingImage
width: 12, ? null
child: CircularProgressIndicator.adaptive( : () async {
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
),
)
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async {
if (sendingOrLoadingImage) return;
if (widget.sendToGroup == null) { if (widget.sendToGroup == null) {
return pushShareImageView(); return pushShareImageView();
} }
await sendImageToSinglePerson(); await sendImageToSinglePerson();
}, },
style: ButtonStyle( child: Row(
padding: WidgetStateProperty.all<EdgeInsets>( mainAxisSize: MainAxisSize.min,
const EdgeInsets.symmetric( children: [
vertical: 10, if (sendingOrLoadingImage)
horizontal: 30, const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
Colors.black87,
), ),
), ),
)
else
const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 14,
), ),
label: Text( const SizedBox(width: 8),
Text(
(widget.sendToGroup == null) (widget.sendToGroup == null)
? context.lang.shareImagedEditorShareWith ? context.lang.shareImagedEditorShareWith
: substringBy(widget.sendToGroup!.groupName, 15), : substringBy(
style: const TextStyle(fontSize: 17), widget.sendToGroup!.groupName,
15,
),
),
],
),
), ),
), ),
], ],

View file

@ -40,7 +40,10 @@ class _ChatListViewState extends State<ChatListView> {
bool _hasContacts = false; bool _hasContacts = false;
bool _loading = true; bool _loading = true;
bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty; bool get _hasOpenGroup =>
_groupsNotPinned.isNotEmpty ||
_groupsArchived.isNotEmpty ||
_groupsPinned.isNotEmpty;
GlobalKey searchForOtherUsers = GlobalKey(); GlobalKey searchForOtherUsers = GlobalKey();
bool showFeedbackShortcut = false; bool showFeedbackShortcut = false;
@ -64,21 +67,27 @@ class _ChatListViewState extends State<ChatListView> {
_contactsSub = stream.listen((groups) { _contactsSub = stream.listen((groups) {
if (!mounted) return; if (!mounted) return;
setState(() { 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(); _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList();
_groupsArchived = groups.where((x) => x.archived).toList(); _groupsArchived = groups.where((x) => x.archived).toList();
_loading = false; _loading = false;
}); });
}); });
_contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) { _contactsCountSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((
contacts,
) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_hasContacts = contacts.isNotEmpty; _hasContacts = contacts.isNotEmpty;
}); });
}); });
_countContactRequestStream = twonlyDB.contactsDao.watchContactsRequestedCount().listen((update) { _countContactRequestStream = twonlyDB.contactsDao
.watchContactsRequestedCount()
.listen((update) {
if (update != null) { if (update != null) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -87,7 +96,9 @@ class _ChatListViewState extends State<ChatListView> {
} }
}); });
_countAnnouncedStream = twonlyDB.userDiscoveryDao.watchNewAnnouncementsWithDataCount().listen((update) { _countAnnouncedStream = twonlyDB.userDiscoveryDao
.watchNewAnnouncementsWithDataCount()
.listen((update) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_countAnnouncedUsers = update; _countAnnouncedUsers = update;
@ -101,7 +112,8 @@ class _ChatListViewState extends State<ChatListView> {
changeLog.codeUnits, changeLog.codeUnits,
)).bytes; )).bytes;
if (!userService.currentUser.hideChangeLog && if (!userService.currentUser.hideChangeLog &&
userService.currentUser.lastChangeLogHash.toString() != changeLogHash.toString()) { userService.currentUser.lastChangeLogHash.toString() !=
changeLogHash.toString()) {
await UserService.update((u) { await UserService.update((u) {
u.lastChangeLogHash = changeLogHash; u.lastChangeLogHash = changeLogHash;
}); });
@ -190,11 +202,16 @@ class _ChatListViewState extends State<ChatListView> {
), ),
Center( Center(
child: NotificationBadgeComp( child: NotificationBadgeComp(
backgroundColor: isDarkMode(context) ? Colors.white : Colors.black, backgroundColor: isDarkMode(context)
? Colors.white
: Colors.black,
textColor: isDarkMode(context) ? Colors.black : Colors.white, textColor: isDarkMode(context) ? Colors.black : Colors.white,
count: (_countAnnouncedUsers + _countContactRequest).toString(), count: (_countAnnouncedUsers + _countContactRequest)
.toString(),
child: IconButton( child: IconButton(
color: (_countAnnouncedUsers + _countContactRequest > 0) ? Colors.black : null, color: (_countAnnouncedUsers + _countContactRequest > 0)
? Colors.black
: null,
key: searchForOtherUsers, key: searchForOtherUsers,
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
onPressed: () => context.push(Routes.chatsAddNewUser), onPressed: () => context.push(Routes.chatsAddNewUser),
@ -240,7 +257,10 @@ class _ChatListViewState extends State<ChatListView> {
_groupsNotPinned.length + _groupsNotPinned.length +
(_groupsArchived.isNotEmpty ? 1 : 0), (_groupsArchived.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) { 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(); if (_groupsArchived.isEmpty) return Container();
return ListTile( return ListTile(
title: Text( title: Text(
@ -304,7 +324,9 @@ class _ChatListViewState extends State<ChatListView> {
child: Center( child: Center(
child: FaIcon( child: FaIcon(
FontAwesomeIcons.qrcode, FontAwesomeIcons.qrcode,
color: isDarkMode(context) ? Colors.black : Colors.white, color: isDarkMode(context)
? Colors.black
: Colors.white,
), ),
), ),
), ),

View file

@ -59,7 +59,8 @@ class EmptyChatListComp extends StatelessWidget {
const SizedBox(height: 36), const SizedBox(height: 36),
const Center(child: ProfileQrCodeComp()), const Center(child: ProfileQrCodeComp()),
const SizedBox(height: 36), const SizedBox(height: 36),
MyButton( IntrinsicWidth(
child: MyButton(
onPressed: () => _shareProfile(context), onPressed: () => _shareProfile(context),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -76,6 +77,7 @@ class EmptyChatListComp extends StatelessWidget {
], ],
), ),
), ),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View file

@ -40,8 +40,10 @@ class _ResponseContainerState extends State<ResponseContainer> {
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final messageBox = _message.currentContext?.findRenderObject() as RenderBox?; final messageBox =
final previewBox = _preview.currentContext?.findRenderObject() as RenderBox?; _message.currentContext?.findRenderObject() as RenderBox?;
final previewBox =
_preview.currentContext?.findRenderObject() as RenderBox?;
if (messageBox == null || previewBox == null) { if (messageBox == null || previewBox == null) {
return; return;
} }
@ -64,7 +66,9 @@ class _ResponseContainerState extends State<ResponseContainer> {
return widget.child!; return widget.child!;
} }
return GestureDetector( return GestureDetector(
onTap: widget.scrollToMessage == null ? null : () => widget.scrollToMessage!(widget.msg.quotesMessageId!), onTap: widget.scrollToMessage == null
? null
: () => widget.scrollToMessage!(widget.msg.quotesMessageId!),
child: Container( child: Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8, maxWidth: MediaQuery.of(context).size.width * 0.8,
@ -140,12 +144,16 @@ class _ResponsePreviewState extends State<ResponsePreview> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
_message ??= await twonlyDB.messagesDao.getMessageById(widget.messageId!).getSingleOrNull(); _message ??= await twonlyDB.messagesDao
.getMessageById(widget.messageId!)
.getSingleOrNull();
if (_message?.mediaId != null) { if (_message?.mediaId != null) {
_mediaService = await MediaFileService.fromMediaId(_message!.mediaId!); _mediaService = await MediaFileService.fromMediaId(_message!.mediaId!);
} }
if (_message?.senderId != null) { 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) { if (contact != null) {
_username = getContactDisplayName(contact); _username = getContactDisplayName(contact);
} }
@ -263,15 +271,21 @@ class _ResponsePreviewState extends State<ResponsePreview> {
], ],
), ),
), ),
if (_mediaService != null && _mediaService!.mediaFile.type != MediaType.audio) if (_mediaService != null &&
SizedBox( _mediaService!.mediaFile.type != MediaType.audio)
height: widget.showBorder ? 100 : 210, () {
child: Image.file( final isVideo = _mediaService!.mediaFile.type == MediaType.video;
_mediaService!.mediaFile.type == MediaType.video final pathToCheck = isVideo
? _mediaService!.thumbnailPath ? _mediaService!.thumbnailPath
: _mediaService!.storedPath, : _mediaService!.storedPath;
), if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) {
), return SizedBox(
height: widget.showBorder ? 100 : 210,
child: Image.file(pathToCheck),
);
}
return const SizedBox.shrink();
}(),
], ],
), ),
); );

View file

@ -698,6 +698,15 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
), ),
), ),
], ],

View file

@ -72,7 +72,10 @@ class OpenRequestsListComp extends StatelessWidget {
if (block) { if (block) {
const update = ContactsCompanion(blocked: Value(true)); const update = ContactsCompanion(blocked: Value(true));
if (context.mounted) { 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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: contact.requested ? requestedActions(context, contact) : sendRequestActions(context, contact), children: contact.requested
? requestedActions(context, contact)
: sendRequestActions(context, contact),
), ),
); );
}), }),

View file

@ -66,6 +66,18 @@ class MemoriesFlashbackBannerComp extends StatelessWidget {
Image.file( Image.file(
items.first.mediaService.storedPath, items.first.mediaService.storedPath,
fit: BoxFit.cover, 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( Positioned.fill(
child: DecoratedBox( child: DecoratedBox(

View file

@ -79,20 +79,30 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
_scaleController.value = 1.0; _scaleController.value = 1.0;
} }
_listener = ImageStreamListener((info, _) { _listener = ImageStreamListener(
(info, _) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_imageInfo = info; _imageInfo = info;
}); });
} }
},
onError: (exception, stackTrace) {
if (mounted) {
setState(() {
_imageProvider = null;
_imageInfo = null;
}); });
}
},
);
_resolveImage(); _resolveImage();
} }
void _resolveImage() { void _resolveImage() {
final media = widget.galleryItem.mediaService; final media = widget.galleryItem.mediaService;
final hasThumbnail = media.thumbnailPath.existsSync(); final hasThumbnail = media.thumbnailPath.existsSync() && media.thumbnailPath.lengthSync() > 0;
final hasStored = media.storedPath.existsSync(); final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
final isImageOrGif = final isImageOrGif =
media.mediaFile.type == MediaType.image || media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif; media.mediaFile.type == MediaType.gif;
@ -181,6 +191,17 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
image: _imageProvider!, image: _imageProvider!,
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) {
return ColoredBox(
color: Colors.grey.shade200,
child: const Center(
child: FaIcon(
FontAwesomeIcons.image,
color: Colors.black26,
),
),
);
},
) )
else else
ColoredBox( ColoredBox(

View file

@ -193,6 +193,63 @@ class MemoriesViewState extends State<MemoriesView> {
}); });
} }
Future<void> _showProgressDialog(
String message,
Future<void> Function(void Function(double progress) setProgress) task,
) async {
final progressNotifier = ValueNotifier<double>(0);
// Show non-dismissible progress dialog
// ignore: unawaited_futures
showDialog<void>(
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<double>(
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<void>.delayed(Duration.zero);
await task((p) => progressNotifier.value = p);
} finally {
if (mounted) {
Navigator.of(context).pop();
}
progressNotifier.dispose();
}
}
Future<void> _batchDelete() async { Future<void> _batchDelete() async {
final count = _selectedMediaIds.length; final count = _selectedMediaIds.length;
final confirmed = await showAlertDialog( final confirmed = await showAlertDialog(
@ -204,7 +261,13 @@ class MemoriesViewState extends State<MemoriesView> {
if (!confirmed) return; if (!confirmed) return;
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
for (final mediaId in _selectedMediaIds) { 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 final item = items
.where((e) => e.mediaService.mediaFile.mediaId == mediaId) .where((e) => e.mediaService.mediaFile.mediaId == mediaId)
.firstOrNull; .firstOrNull;
@ -212,7 +275,10 @@ class MemoriesViewState extends State<MemoriesView> {
item.mediaService.fullMediaRemoval(); item.mediaService.fullMediaRemoval();
} }
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
setProgress((i + 1) / selectedList.length);
} }
},
);
setState(_selectedMediaIds.clear); setState(_selectedMediaIds.clear);
@ -226,9 +292,15 @@ class MemoriesViewState extends State<MemoriesView> {
Future<void> _batchExport() async { Future<void> _batchExport() async {
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
final selectedList = _selectedMediaIds.toList();
if (selectedList.isEmpty) return;
try { try {
for (final mediaId in _selectedMediaIds) { await _showProgressDialog(
'Exporting memories...',
(setProgress) async {
for (var i = 0; i < selectedList.length; i++) {
final mediaId = selectedList[i];
final item = items final item = items
.where((e) => e.mediaService.mediaFile.mediaId == mediaId) .where((e) => e.mediaService.mediaFile.mediaId == mediaId)
.firstOrNull; .firstOrNull;
@ -242,7 +314,12 @@ class MemoriesViewState extends State<MemoriesView> {
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt); await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
} }
} }
setProgress((i + 1) / selectedList.length);
} }
},
);
setState(_selectedMediaIds.clear);
if (!mounted) return; if (!mounted) return;
showSnackbar( showSnackbar(
@ -258,26 +335,36 @@ class MemoriesViewState extends State<MemoriesView> {
Future<void> _batchFavorite() async { Future<void> _batchFavorite() async {
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
final selectedList = _selectedMediaIds.toList();
if (selectedList.isEmpty) return;
var favCount = 0; var favCount = 0;
for (final item in items) { for (final item in items) {
if (_selectedMediaIds.contains(item.mediaService.mediaFile.mediaId)) { if (selectedList.contains(item.mediaService.mediaFile.mediaId)) {
if (item.mediaService.mediaFile.isFavorite) { if (item.mediaService.mediaFile.isFavorite) {
favCount++; favCount++;
} }
} }
} }
final areAllFav = final areAllFav =
_selectedMediaIds.isNotEmpty && favCount == _selectedMediaIds.length; selectedList.isNotEmpty && favCount == selectedList.length;
final targetFav = !areAllFav; final targetFav = !areAllFav;
for (final mediaId in _selectedMediaIds) { 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( await twonlyDB.mediaFilesDao.updateMedia(
mediaId, mediaId,
MediaFilesCompanion(isFavorite: Value(targetFav)), MediaFilesCompanion(isFavorite: Value(targetFav)),
); );
setProgress((i + 1) / selectedList.length);
} }
},
);
setState(() {}); setState(_selectedMediaIds.clear);
} }
@override @override

View file

@ -347,6 +347,15 @@ class _SynchronizedImageViewerScreenState
backgroundDecoration: const BoxDecoration( backgroundDecoration: const BoxDecoration(
color: Colors.transparent, color: Colors.transparent,
), ),
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
scaleStateChangedCallback: (state) { scaleStateChangedCallback: (state) {
final zoomed = final zoomed =
state != PhotoViewScaleState.initial; state != PhotoViewScaleState.initial;

View file

@ -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_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.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/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart';
class BackupRecoveryView extends StatefulWidget { class BackupRecoveryView extends StatefulWidget {
const BackupRecoveryView({super.key}); const BackupRecoveryView({super.key});
@ -63,70 +64,6 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
}); });
} }
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<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -164,7 +101,7 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
onPressed: () => _showBackupExplanation(context), onPressed: () => showBackupExplanation(context),
icon: const FaIcon(FontAwesomeIcons.circleInfo), icon: const FaIcon(FontAwesomeIcons.circleInfo),
color: iconColor, color: iconColor,
iconSize: 20, iconSize: 20,

View file

@ -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/model/json/backup.model.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class BackupView extends StatefulWidget { class BackupView extends StatefulWidget {
const BackupView({super.key}); const BackupView({super.key});
@ -176,7 +177,8 @@ class _BackupViewState extends State<BackupView> {
]), ]),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
OutlinedButton( MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: _isLoading onPressed: _isLoading
? null ? null
: () async { : () async {
@ -194,7 +196,8 @@ class _BackupViewState extends State<BackupView> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Center( Center(
child: FilledButton( child: MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () => onPressed: () =>
context.push(Routes.settingsBackupSetup, extra: true), context.push(Routes.settingsBackupSetup, extra: true),
child: Text( child: Text(

View file

@ -6,6 +6,7 @@ import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.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'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart';
class SetupBackupView extends StatefulWidget { class SetupBackupView extends StatefulWidget {
@ -76,13 +77,7 @@ class _SetupBackupViewState extends State<SetupBackupView> {
title: const Text('twonly Backup'), title: const Text('twonly Backup'),
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () => showBackupExplanation(context),
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo), icon: const FaIcon(FontAwesomeIcons.circleInfo),
iconSize: 18, iconSize: 18,
), ),
@ -131,7 +126,8 @@ class _SetupBackupViewState extends State<SetupBackupView> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Center( Center(
child: FilledButton.icon( child: MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: onPressed:
(!_isLoading && (!_isLoading &&
(_passwordController.text == (_passwordController.text ==
@ -140,18 +136,27 @@ class _SetupBackupViewState extends State<SetupBackupView> {
!kReleaseMode)) !kReleaseMode))
? _updateBackupPassword ? _updateBackupPassword
: null, : null,
icon: _isLoading child: Row(
? const SizedBox( mainAxisSize: MainAxisSize.min,
children: [
if (_isLoading)
const SizedBox(
height: 12, height: 12,
width: 12, width: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1), child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
) )
: const Icon(Icons.lock_clock_rounded), else
label: Text( const Icon(Icons.lock_clock_rounded),
const SizedBox(width: 8),
Text(
userService.currentUser.isBackupEnabled userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup ? context.lang.backupEnableBackup
: context.lang.backupChangePassword, : context.lang.backupChangePassword,
), ),
],
),
), ),
), ),
], ],

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 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'; import 'package:twonly/src/visual/elements/my_input.element.dart';
Future<bool> isSecurePassword(String password) async { Future<bool> 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<void>(
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'),
),
],
),
),
);
},
);
}

View file

@ -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:device_info_plus/device_info_plus.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.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/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.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/contact_us/submit_message.view.dart';
import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq.view.dart';
@ -29,13 +33,29 @@ class _ContactUsState extends State<ContactUsView> {
int? _selectedFeedback; int? _selectedFeedback;
String? _selectedReason; String? _selectedReason;
String? debugLogDownloadToken; String? debugLogDownloadToken;
String? debugLogEncryptionKey;
Future<String?> uploadDebugLog() async { Future<(String, String)?> uploadDebugLog() async {
if (debugLogDownloadToken != null) return debugLogDownloadToken; if (debugLogDownloadToken != null && debugLogEncryptionKey != null) {
return (debugLogDownloadToken!, debugLogEncryptionKey!);
}
final downloadToken = getRandomUint8List(32); final downloadToken = getRandomUint8List(32);
final encryptionKey = getRandomUint8List(32);
final debugLog = await loadLogFile(); 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() final messageOnSuccess = TextMessage()
..body = [] ..body = []
..userId = Int64(); ..userId = Int64();
@ -43,7 +63,7 @@ class _ContactUsState extends State<ContactUsView> {
final uploadRequest = UploadRequest( final uploadRequest = UploadRequest(
messagesOnSuccess: [messageOnSuccess], messagesOnSuccess: [messageOnSuccess],
downloadTokens: [downloadToken], downloadTokens: [downloadToken],
encryptedData: debugLog.codeUnits, encryptedData: encryptedData,
); );
final uploadRequestBytes = uploadRequest.writeToBuffer(); final uploadRequestBytes = uploadRequest.writeToBuffer();
@ -71,10 +91,13 @@ class _ContactUsState extends State<ContactUsView> {
final response = await requestMultipart.send(); final response = await requestMultipart.send();
if (response.statusCode == 200) { if (response.statusCode == 200) {
final tokenHex = uint8ListToHex(downloadToken);
final keyHex = uint8ListToHex(encryptionKey);
setState(() { setState(() {
debugLogDownloadToken = uint8ListToHex(downloadToken); debugLogDownloadToken = tokenHex;
debugLogEncryptionKey = keyHex;
}); });
return debugLogDownloadToken; return (tokenHex, keyHex);
} }
return null; return null;
} }
@ -108,13 +131,13 @@ class _ContactUsState extends State<ContactUsView> {
} }
if (includeDebugLog) { if (includeDebugLog) {
String? token; (String, String)? result;
try { try {
token = await uploadDebugLog(); result = await uploadDebugLog();
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
} }
if (token == null) { if (result == null) {
if (!mounted) return null; if (!mounted) return null;
showSnackbar(context, 'Could not upload the debug log!'); showSnackbar(context, 'Could not upload the debug log!');
setState(() { setState(() {
@ -122,7 +145,10 @@ class _ContactUsState extends State<ContactUsView> {
}); });
return null; 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(() { setState(() {
@ -238,17 +264,8 @@ $debugLogToken
), ),
), ),
), ),
ElevatedButton.icon( MyButton(
icon: isLoading variant: MyButtonVariant.primaryDense,
? SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
),
)
: const FaIcon(FontAwesomeIcons.angleRight),
onPressed: isLoading onPressed: isLoading
? null ? null
: () async { : () async {
@ -263,7 +280,24 @@ $debugLogToken
Navigator.pop(context); 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),
],
),
), ),
], ],
), ),

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class SubmitMessage extends StatefulWidget { class SubmitMessage extends StatefulWidget {
const SubmitMessage({required this.fullMessage, super.key}); const SubmitMessage({required this.fullMessage, super.key});
@ -100,7 +101,8 @@ class _ContactUsState extends State<SubmitMessage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
ElevatedButton( MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: isLoading ? null : _submitFeedback, onPressed: isLoading ? null : _submitFeedback,
child: Text(context.lang.submit), child: Text(context.lang.submit),
), ),

BIN
test.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 B