mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-13 10:42:12 +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() {
|
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
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,34 +12,61 @@ Future<bool> createThumbnailsForVideo(
|
||||||
) async {
|
) async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
if (destinationFile.existsSync()) {
|
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
|
||||||
return true;
|
Log.warn('Source video file does not exist or is empty.');
|
||||||
}
|
try {
|
||||||
|
if (destinationFile.existsSync()) {
|
||||||
final images = await ProVideoEditor.instance.getThumbnails(
|
destinationFile.deleteSync();
|
||||||
ThumbnailConfigs(
|
}
|
||||||
video: EditorVideo.file(sourceFile),
|
} catch (_) {}
|
||||||
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.',
|
|
||||||
);
|
|
||||||
return false;
|
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(
|
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();
|
||||||
Log.info(
|
|
||||||
'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.',
|
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
|
||||||
);
|
Log.info(
|
||||||
return true;
|
'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) {
|
} 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()) {
|
||||||
|
destinationFile.deleteSync();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (destinationFile.existsSync()) {
|
if (destinationFile.existsSync()) {
|
||||||
return true;
|
if (destinationFile.lengthSync() > 0) {
|
||||||
|
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 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(
|
final webp = await FlutterImageCompress.compressWithList(
|
||||||
pngBytes,
|
pngBytes,
|
||||||
format: CompressFormat.webp,
|
format: CompressFormat.webp,
|
||||||
quality: 85,
|
quality: 85,
|
||||||
);
|
);
|
||||||
destinationFile.writeAsBytesSync(webp);
|
if (webp.isEmpty) {
|
||||||
|
Log.error('GIF thumbnail compression returned empty.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await destinationFile.writeAsBytes(webp);
|
||||||
|
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
Log.info(
|
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
|
||||||
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
|
Log.info(
|
||||||
);
|
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
|
||||||
return true;
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (destinationFile.existsSync()) {
|
||||||
|
destinationFile.deleteSync();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error('Error creating GIF thumbnail: $e');
|
Log.error('Error creating GIF thumbnail: $e');
|
||||||
|
try {
|
||||||
|
if (destinationFile.existsSync()) {
|
||||||
|
destinationFile.deleteSync();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
return false;
|
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 {
|
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,23 +216,25 @@ class MemoriesService {
|
||||||
);
|
);
|
||||||
_notifyState();
|
_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);
|
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.mediaFile.hasThumbnail) {
|
||||||
if (mediaService.thumbnailPath.existsSync()) {
|
if (mediaService.thumbnailPath.existsSync() &&
|
||||||
|
mediaService.thumbnailPath.lengthSync() > 0) {
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
mediaFile.mediaId,
|
mediaFile.mediaId,
|
||||||
const MediaFilesCompanion(hasThumbnail: Value(true)),
|
const MediaFilesCompanion(hasThumbnail: Value(true)),
|
||||||
|
|
@ -235,18 +243,48 @@ class MemoriesService {
|
||||||
await mediaService.createThumbnail();
|
await mediaService.createThumbnail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_updateMigrationCount(_currentState.filesToMigrate - 1);
|
} catch (e) {
|
||||||
|
Log.error(
|
||||||
|
'Error creating thumbnail for ${mediaFile.mediaId}: $e',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
_updateMigrationCount(_currentState.filesToMigrate - 1);
|
||||||
_updateMigrationCount(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _dbSubscription?.cancel();
|
_updateMigrationCount(0);
|
||||||
_dbSubscription = twonlyDB.mediaFilesDao
|
|
||||||
.watchAllStoredMediaFiles()
|
// Phase 2: Background — hash, crop analysis, size calculation.
|
||||||
.listen(_processMediaFilesStream);
|
// Each DB write here fires the stream subscription above, keeping
|
||||||
|
// the gallery state fresh without a separate notification step.
|
||||||
|
await _backgroundProcessPendingFiles(pendingFiles);
|
||||||
} catch (e) {
|
} 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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,50 +297,53 @@ class _ShareImageView extends State<ShareImageView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FilledButton.icon(
|
MyButton(
|
||||||
icon: !mediaStoreFutureReady || sendingImage
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
? SizedBox(
|
onPressed:
|
||||||
height: 12,
|
!mediaStoreFutureReady ||
|
||||||
width: 12,
|
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(
|
child: CircularProgressIndicator.adaptive(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
Colors.black87,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
else
|
||||||
onPressed: () async {
|
const FaIcon(
|
||||||
if (!mediaStoreFutureReady || widget.selectedGroupIds.isEmpty) {
|
FontAwesomeIcons.solidPaperPlane,
|
||||||
return;
|
size: 14,
|
||||||
}
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
setState(() {
|
Text(
|
||||||
sendingImage = true;
|
'${context.lang.shareImagedEditorSendImage} (${widget.selectedGroupIds.length})',
|
||||||
});
|
),
|
||||||
|
],
|
||||||
// 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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,49 +723,57 @@ 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,
|
if (widget.sendToGroup == null) {
|
||||||
valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.inversePrimary),
|
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 SizedBox(width: 8),
|
||||||
: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
Text(
|
||||||
onPressed: () async {
|
(widget.sendToGroup == null)
|
||||||
if (sendingOrLoadingImage) return;
|
? context.lang.shareImagedEditorShareWith
|
||||||
if (widget.sendToGroup == null) {
|
: substringBy(
|
||||||
return pushShareImageView();
|
widget.sendToGroup!.groupName,
|
||||||
}
|
15,
|
||||||
await sendImageToSinglePerson();
|
),
|
||||||
},
|
),
|
||||||
style: ButtonStyle(
|
],
|
||||||
padding: WidgetStateProperty.all<EdgeInsets>(
|
|
||||||
const EdgeInsets.symmetric(
|
|
||||||
vertical: 10,
|
|
||||||
horizontal: 30,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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 _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,35 +67,43 @@ 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
|
||||||
if (update != null) {
|
.watchContactsRequestedCount()
|
||||||
if (!mounted) return;
|
.listen((update) {
|
||||||
setState(() {
|
if (update != null) {
|
||||||
_countContactRequest = update;
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_countContactRequest = update;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_countAnnouncedStream = twonlyDB.userDiscoveryDao.watchNewAnnouncementsWithDataCount().listen((update) {
|
_countAnnouncedStream = twonlyDB.userDiscoveryDao
|
||||||
if (!mounted) return;
|
.watchNewAnnouncementsWithDataCount()
|
||||||
setState(() {
|
.listen((update) {
|
||||||
_countAnnouncedUsers = update;
|
if (!mounted) return;
|
||||||
});
|
setState(() {
|
||||||
});
|
_countAnnouncedUsers = update;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final changeLog = await rootBundle.loadString('CHANGELOG.md');
|
final changeLog = await rootBundle.loadString('CHANGELOG.md');
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -59,21 +59,23 @@ 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(
|
||||||
onPressed: () => _shareProfile(context),
|
child: MyButton(
|
||||||
child: Row(
|
onPressed: () => _shareProfile(context),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const FaIcon(FontAwesomeIcons.shareNodes, size: 20),
|
children: [
|
||||||
const SizedBox(width: 8),
|
const FaIcon(FontAwesomeIcons.shareNodes, size: 20),
|
||||||
Text(
|
const SizedBox(width: 8),
|
||||||
context.lang.emptyChatListShareBtn,
|
Text(
|
||||||
style: const TextStyle(
|
context.lang.emptyChatListShareBtn,
|
||||||
fontSize: 16,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -79,20 +79,30 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||||
_scaleController.value = 1.0;
|
_scaleController.value = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_listener = ImageStreamListener((info, _) {
|
_listener = ImageStreamListener(
|
||||||
if (mounted) {
|
(info, _) {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_imageInfo = info;
|
setState(() {
|
||||||
});
|
_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(
|
||||||
|
|
|
||||||
|
|
@ -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,15 +261,24 @@ 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();
|
||||||
final item = items
|
|
||||||
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
await _showProgressDialog(
|
||||||
.firstOrNull;
|
'Deleting memories...',
|
||||||
if (item != null) {
|
(setProgress) async {
|
||||||
item.mediaService.fullMediaRemoval();
|
for (var i = 0; i < selectedList.length; i++) {
|
||||||
}
|
final mediaId = selectedList[i];
|
||||||
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
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);
|
setState(_selectedMediaIds.clear);
|
||||||
|
|
||||||
|
|
@ -226,23 +292,34 @@ 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(
|
||||||
final item = items
|
'Exporting memories...',
|
||||||
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
(setProgress) async {
|
||||||
.firstOrNull;
|
for (var i = 0; i < selectedList.length; i++) {
|
||||||
if (item != null) {
|
final mediaId = selectedList[i];
|
||||||
final media = item.mediaService;
|
final item = items
|
||||||
if (media.mediaFile.type == MediaType.video) {
|
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
||||||
await saveVideoToGallery(media.storedPath.path);
|
.firstOrNull;
|
||||||
} else if (media.mediaFile.type == MediaType.image ||
|
if (item != null) {
|
||||||
media.mediaFile.type == MediaType.gif) {
|
final media = item.mediaService;
|
||||||
final imageBytes = await media.storedPath.readAsBytes();
|
if (media.mediaFile.type == MediaType.video) {
|
||||||
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
|
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;
|
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(
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
targetFav ? 'Adding to favorites...' : 'Removing from favorites...',
|
||||||
mediaId,
|
(setProgress) async {
|
||||||
MediaFilesCompanion(isFavorite: Value(targetFav)),
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,17 +136,26 @@ 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),
|
||||||
userService.currentUser.isBackupEnabled
|
const SizedBox(width: 8),
|
||||||
? context.lang.backupEnableBackup
|
Text(
|
||||||
: context.lang.backupChangePassword,
|
userService.currentUser.isBackupEnabled
|
||||||
|
? context.lang.backupEnableBackup
|
||||||
|
: context.lang.backupChangePassword,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
BIN
test.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 620 B |
Loading…
Reference in a new issue