mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-13 08:42:13 +00:00
replacing more buttons
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
e2cf5ec74a
commit
12dce4f52d
26 changed files with 841 additions and 390 deletions
|
|
@ -141,7 +141,9 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
Stream<List<MediaFile>> watchAllStoredMediaFiles() {
|
||||
final query =
|
||||
(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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235
|
||||
Subproject commit c95e98ca929d630ead028d84e13934b30dbeba3b
|
||||
|
|
@ -213,7 +213,12 @@ class MediaFileService {
|
|||
}
|
||||
|
||||
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 &&
|
||||
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<void> 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);
|
||||
|
|
|
|||
|
|
@ -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<bool> 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<bool> createThumbnailsForImage(
|
||||
|
|
@ -47,6 +75,26 @@ Future<bool> 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<bool> 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<bool> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,12 @@ class MemoriesService {
|
|||
|
||||
Future<void> _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<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) {
|
||||
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<void> _backgroundProcessPendingFiles(
|
||||
List<MediaFile> 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ enum MyButtonVariant {
|
|||
primary,
|
||||
secondary,
|
||||
text,
|
||||
primaryMiddle,
|
||||
primaryDense,
|
||||
secondaryDense,
|
||||
}
|
||||
|
|
@ -142,6 +143,23 @@ class _MyButtonState extends State<MyButton>
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<SaveToGalleryButton> {
|
|||
|
||||
@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<SaveToGalleryButton> {
|
|||
_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(
|
||||
|
|
|
|||
|
|
@ -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<ShareImageView> {
|
|||
|
||||
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<ShareImageView> {
|
|||
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<ShareImageView> {
|
|||
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<ShareImageView> {
|
|||
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<ShareImageView> {
|
|||
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<ShareImageView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
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<EdgeInsets>(
|
||||
const EdgeInsets.symmetric(vertical: 10, horizontal: 30),
|
||||
),
|
||||
backgroundColor: WidgetStateProperty.all<Color>(
|
||||
!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})',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<ShareImageEditorView> {
|
|||
|
||||
List<Widget> 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 <Widget>[
|
||||
|
|
@ -290,9 +293,13 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
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<ShareImageEditorView> {
|
|||
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<ShareImageEditorView> {
|
|||
|
||||
List<Widget> 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<ShareImageEditorView> {
|
|||
}
|
||||
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<ShareImageEditorView> {
|
|||
}
|
||||
|
||||
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()) {
|
||||
mediaService.overlayImagePath.deleteSync();
|
||||
}
|
||||
|
|
@ -512,14 +534,12 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
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<ShareImageEditorView> {
|
|||
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<ShareImageEditorView> {
|
|||
),
|
||||
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<EdgeInsets>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
|
||||
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<ChatListView> {
|
|||
_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<ChatListView> {
|
|||
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<ChatListView> {
|
|||
),
|
||||
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<ChatListView> {
|
|||
_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<ChatListView> {
|
|||
child: Center(
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.qrcode,
|
||||
color: isDarkMode(context) ? Colors.black : Colors.white,
|
||||
color: isDarkMode(context)
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ class _ResponseContainerState extends State<ResponseContainer> {
|
|||
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<ResponseContainer> {
|
|||
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<ResponsePreview> {
|
|||
}
|
||||
|
||||
Future<void> 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<ResponsePreview> {
|
|||
],
|
||||
),
|
||||
),
|
||||
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();
|
||||
}(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -698,6 +698,15 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
),
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.white38,
|
||||
size: 64,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -79,20 +79,30 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
|||
_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<MemoriesThumbnailComp>
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
final count = _selectedMediaIds.length;
|
||||
final confirmed = await showAlertDialog(
|
||||
|
|
@ -204,15 +261,24 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
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<MemoriesView> {
|
|||
|
||||
Future<void> _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<MemoriesView> {
|
|||
|
||||
Future<void> _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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -164,7 +101,7 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
|||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => _showBackupExplanation(context),
|
||||
onPressed: () => showBackupExplanation(context),
|
||||
icon: const FaIcon(FontAwesomeIcons.circleInfo),
|
||||
color: iconColor,
|
||||
iconSize: 20,
|
||||
|
|
|
|||
|
|
@ -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<BackupView> {
|
|||
]),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
MyButton(
|
||||
variant: MyButtonVariant.primaryMiddle,
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () async {
|
||||
|
|
@ -194,7 +196,8 @@ class _BackupViewState extends State<BackupView> {
|
|||
),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: FilledButton(
|
||||
child: MyButton(
|
||||
variant: MyButtonVariant.secondaryDense,
|
||||
onPressed: () =>
|
||||
context.push(Routes.settingsBackupSetup, extra: true),
|
||||
child: Text(
|
||||
|
|
|
|||
|
|
@ -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<SetupBackupView> {
|
|||
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<SetupBackupView> {
|
|||
),
|
||||
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<SetupBackupView> {
|
|||
!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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ContactUsView> {
|
|||
int? _selectedFeedback;
|
||||
String? _selectedReason;
|
||||
String? debugLogDownloadToken;
|
||||
String? debugLogEncryptionKey;
|
||||
|
||||
Future<String?> 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<ContactUsView> {
|
|||
final uploadRequest = UploadRequest(
|
||||
messagesOnSuccess: [messageOnSuccess],
|
||||
downloadTokens: [downloadToken],
|
||||
encryptedData: debugLog.codeUnits,
|
||||
encryptedData: encryptedData,
|
||||
);
|
||||
|
||||
final uploadRequestBytes = uploadRequest.writeToBuffer();
|
||||
|
|
@ -71,10 +91,13 @@ class _ContactUsState extends State<ContactUsView> {
|
|||
|
||||
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<ContactUsView> {
|
|||
}
|
||||
|
||||
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<ContactUsView> {
|
|||
});
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<SubmitMessage> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
MyButton(
|
||||
variant: MyButtonVariant.primaryDense,
|
||||
onPressed: isLoading ? null : _submitFeedback,
|
||||
child: Text(context.lang.submit),
|
||||
),
|
||||
|
|
|
|||
BIN
test.jpg
BIN
test.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 620 B |
Loading…
Reference in a new issue