mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 19:42:12 +00:00
remove permissions again
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
This commit is contained in:
parent
849f748968
commit
d03c42659c
15 changed files with 396 additions and 105 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Changelog
|
||||
|
||||
## 0.2.25
|
||||
## 0.2.26
|
||||
|
||||
- New: Import images from the gallery
|
||||
- Improved: Media files are now stored in the dedicated "twonly" album
|
||||
|
|
|
|||
|
|
@ -82,8 +82,6 @@
|
|||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,32 @@ import android.content.Context
|
|||
import io.crates.keyring.Keyring
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import android.os.Bundle
|
||||
import android.net.Uri
|
||||
import java.io.InputStream
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
private val CHANNEL = "eu.twonly/photo_picker"
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
|
||||
private lateinit var pickMultipleMedia: ActivityResultLauncher<PickVisualMediaRequest>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
|
||||
pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
val uriStrings = uris.map { it.toString() }
|
||||
pendingResult?.success(uriStrings)
|
||||
} else {
|
||||
pendingResult?.success(emptyList<String>())
|
||||
}
|
||||
pendingResult = null
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
|
|
@ -36,5 +57,35 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
Keyring.initializeNdkContext(applicationContext)
|
||||
|
||||
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"pickImages" -> {
|
||||
pendingResult = result
|
||||
pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
"getUriBytes" -> {
|
||||
val uriString = call.argument<String>("uri")
|
||||
if (uriString != null) {
|
||||
try {
|
||||
val uri = Uri.parse(uriString)
|
||||
val inputStream: InputStream? = contentResolver.openInputStream(uri)
|
||||
if (inputStream != null) {
|
||||
val bytes = inputStream.readBytes()
|
||||
inputStream.close()
|
||||
result.success(bytes)
|
||||
} else {
|
||||
result.error("UNAVAILABLE", "Could not open InputStream", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_ARGUMENT", "URI string is null", null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1484,6 +1484,12 @@ abstract class AppLocalizations {
|
|||
/// **'The image will be irrevocably deleted.'**
|
||||
String get deleteImageBody;
|
||||
|
||||
/// No description provided for @deleteMemoriesBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
||||
String deleteMemoriesBody(num count);
|
||||
|
||||
/// No description provided for @settingsBackup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -772,6 +772,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get deleteImageBody => 'Das Bild wird unwiderruflich gelöscht.';
|
||||
|
||||
@override
|
||||
String deleteMemoriesBody(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Die $count Bilder werden unwiderruflich gelöscht.',
|
||||
one: 'Das Bild wird unwiderruflich gelöscht.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsBackup => 'Backup';
|
||||
|
||||
|
|
|
|||
|
|
@ -766,6 +766,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get deleteImageBody => 'The image will be irrevocably deleted.';
|
||||
|
||||
@override
|
||||
String deleteMemoriesBody(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'The $count images will be irrevocably deleted.',
|
||||
one: 'The image will be irrevocably deleted.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsBackup => 'Backup';
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 1da4b1c1bf172914fffad86d39a6ce4ca2845c01
|
||||
Subproject commit 189bf8f4dbe2bee4f19a15b9640b8826e4f2e235
|
||||
25
lib/src/services/android_photo_picker.service.dart
Normal file
25
lib/src/services/android_photo_picker.service.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
class AndroidPhotoPickerService {
|
||||
static const MethodChannel _channel = MethodChannel('eu.twonly/photo_picker');
|
||||
|
||||
/// Launches the native Android Photo Picker and returns a list of URIs.
|
||||
static Future<List<String>> pickImages() async {
|
||||
try {
|
||||
final result = await _channel.invokeListMethod<String>('pickImages');
|
||||
return result ?? [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the raw bytes from a content URI using the Android ContentResolver.
|
||||
static Future<Uint8List?> getUriBytes(String uri) async {
|
||||
try {
|
||||
final bytes = await _channel.invokeMethod<Uint8List>('getUriBytes', {'uri': uri});
|
||||
return bytes;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path/path.dart';
|
||||
|
|
@ -310,6 +311,7 @@ class MediaFileService {
|
|||
} else {
|
||||
await saveImageToGallery(
|
||||
storedPath.readAsBytesSync(),
|
||||
createdAt: mediaFile.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -447,106 +449,41 @@ class MediaFileService {
|
|||
|
||||
try {
|
||||
final bytes = storedPath.readAsBytesSync();
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) {
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final result = await compute(_processImageCrop, bytes);
|
||||
|
||||
var minY = 0;
|
||||
var maxY = image.height - 1;
|
||||
var minX = 0;
|
||||
var maxX = image.width - 1;
|
||||
|
||||
var found = false;
|
||||
for (var y = 0; y < image.height; y++) {
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
minY = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var y = image.height - 1; y >= minY; y--) {
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
maxY = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
minX = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var x = image.width - 1; x >= minX; x--) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
maxX = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
final newWidth = maxX - minX + 1;
|
||||
final newHeight = maxY - minY + 1;
|
||||
|
||||
if (minY > 0 ||
|
||||
maxY < image.height - 1 ||
|
||||
minX > 0 ||
|
||||
maxX < image.width - 1) {
|
||||
if (newWidth > 10 && newHeight > 10) {
|
||||
final cropped = img.copyCrop(
|
||||
image,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
);
|
||||
final pngBytes = img.encodePng(cropped);
|
||||
if (result.isCropped && result.pngBytes != null) {
|
||||
try {
|
||||
final webpBytes = await FlutterImageCompress.compressWithList(
|
||||
pngBytes,
|
||||
result.pngBytes!,
|
||||
format: CompressFormat.webp,
|
||||
quality: 90,
|
||||
);
|
||||
storedPath.writeAsBytesSync(webpBytes);
|
||||
|
||||
if (thumbnailPath.existsSync()) {
|
||||
thumbnailPath.deleteSync();
|
||||
|
||||
if (webpBytes.isNotEmpty) {
|
||||
storedPath.writeAsBytesSync(webpBytes);
|
||||
} else {
|
||||
Log.warn('WebP compression returned empty, falling back to PNG');
|
||||
storedPath.writeAsBytesSync(result.pngBytes!);
|
||||
}
|
||||
await createThumbnail();
|
||||
final checksum = await sha256File(storedPath);
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
MediaFilesCompanion(
|
||||
hasCropAnalyzed: const Value(true),
|
||||
storedFileHash: Value(Uint8List.fromList(checksum)),
|
||||
),
|
||||
);
|
||||
await updateFromDB();
|
||||
return;
|
||||
} catch (e) {
|
||||
Log.error('Error compressing to WebP, falling back to PNG: $e');
|
||||
storedPath.writeAsBytesSync(result.pngBytes!);
|
||||
}
|
||||
|
||||
if (thumbnailPath.existsSync()) {
|
||||
thumbnailPath.deleteSync();
|
||||
}
|
||||
await createThumbnail();
|
||||
final checksum = await sha256File(storedPath);
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
MediaFilesCompanion(
|
||||
hasCropAnalyzed: const Value(true),
|
||||
storedFileHash: Value(Uint8List.fromList(checksum)),
|
||||
),
|
||||
);
|
||||
await updateFromDB();
|
||||
return;
|
||||
}
|
||||
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
|
|
@ -566,3 +503,89 @@ class MediaFileService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CropResult {
|
||||
const _CropResult(this.pngBytes, this.isCropped);
|
||||
final Uint8List? pngBytes;
|
||||
final bool isCropped;
|
||||
}
|
||||
|
||||
_CropResult _processImageCrop(Uint8List bytes) {
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) return const _CropResult(null, false);
|
||||
|
||||
var minY = 0;
|
||||
var maxY = image.height - 1;
|
||||
var minX = 0;
|
||||
var maxX = image.width - 1;
|
||||
|
||||
var found = false;
|
||||
for (var y = 0; y < image.height; y++) {
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
minY = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var y = image.height - 1; y >= minY; y--) {
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
maxY = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
minX = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var x = image.width - 1; x >= minX; x--) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
maxX = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
final newWidth = maxX - minX + 1;
|
||||
final newHeight = maxY - minY + 1;
|
||||
|
||||
if (minY > 0 ||
|
||||
maxY < image.height - 1 ||
|
||||
minX > 0 ||
|
||||
maxX < image.width - 1) {
|
||||
if (newWidth > 10 && newHeight > 10) {
|
||||
final cropped = img.copyCrop(
|
||||
image,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
);
|
||||
final pngBytes = img.encodePng(cropped);
|
||||
return _CropResult(pngBytes, true);
|
||||
}
|
||||
}
|
||||
|
||||
return const _CropResult(null, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:convert/convert.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
|
@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -31,12 +33,42 @@ extension ShortCutsExtension on BuildContext {
|
|||
}
|
||||
}
|
||||
|
||||
Future<String?> saveImageToGallery(Uint8List imageBytes) async {
|
||||
Future<String?> saveImageToGallery(
|
||||
Uint8List imageBytes, {
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
var bytesToProcess = imageBytes;
|
||||
|
||||
if (createdAt != null) {
|
||||
try {
|
||||
final image = img.decodeImage(imageBytes);
|
||||
if (image != null) {
|
||||
final formattedDate = DateFormat(
|
||||
'yyyy:MM:dd HH:mm:ss',
|
||||
).format(createdAt);
|
||||
image.exif.imageIfd[0x0132] = img.IfdValueAscii(
|
||||
formattedDate,
|
||||
); // DateTime
|
||||
image.exif.exifIfd[0x9003] = img.IfdValueAscii(
|
||||
formattedDate,
|
||||
); // DateTimeOriginal
|
||||
image.exif.exifIfd[0x9004] = img.IfdValueAscii(
|
||||
formattedDate,
|
||||
); // DateTimeDigitized
|
||||
|
||||
bytesToProcess = img.encodeJpg(image);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
final jpgImages = await FlutterImageCompress.compressWithList(
|
||||
// ignore: avoid_redundant_argument_values
|
||||
format: CompressFormat.jpeg,
|
||||
imageBytes,
|
||||
bytesToProcess,
|
||||
quality: 100,
|
||||
keepExif: true,
|
||||
);
|
||||
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||
if (!hasAccess) {
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
final confirmed = await showAlertDialog(
|
||||
context,
|
||||
context.lang.deleteImageTitle,
|
||||
context.lang.deleteImageBody,
|
||||
context.lang.deleteMemoriesBody(count),
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
|
@ -239,7 +239,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
} else if (media.mediaFile.type == MediaType.image ||
|
||||
media.mediaFile.type == MediaType.gif) {
|
||||
final imageBytes = await media.storedPath.readAsBytes();
|
||||
await saveImageToGallery(imageBytes);
|
||||
await saveImageToGallery(imageBytes, createdAt: media.mediaFile.createdAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ class _SynchronizedImageViewerScreenState
|
|||
} else if (item.mediaFile.type == MediaType.image ||
|
||||
item.mediaFile.type == MediaType.gif) {
|
||||
final imageBytes = await item.storedPath.readAsBytes();
|
||||
await saveImageToGallery(imageBytes);
|
||||
await saveImageToGallery(imageBytes, createdAt: item.mediaFile.createdAt);
|
||||
}
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:exif/exif.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
|
@ -10,6 +11,7 @@ import 'package:photo_manager/photo_manager.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/android_photo_picker.service.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||
|
|
@ -37,7 +39,106 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkPermissionAndLoad();
|
||||
if (Platform.isAndroid) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_launchAndroidPicker();
|
||||
});
|
||||
} else {
|
||||
_checkPermissionAndLoad();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchAndroidPicker() async {
|
||||
final uris = await AndroidPhotoPickerService.pickImages();
|
||||
if (uris.isEmpty) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImporting = true;
|
||||
_importProgress = 0;
|
||||
_importStatus = context.lang.importGalleryStarting;
|
||||
});
|
||||
|
||||
final total = uris.length;
|
||||
var importedCount = 0;
|
||||
var duplicated = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
for (final uri in uris) {
|
||||
try {
|
||||
setState(() {
|
||||
_importStatus = context.lang.importGalleryImportingOf(
|
||||
importedCount + failedCount + 1,
|
||||
total,
|
||||
);
|
||||
_importProgress = (importedCount + failedCount) / total;
|
||||
});
|
||||
|
||||
final bytes = await AndroidPhotoPickerService.getUriBytes(uri);
|
||||
if (bytes == null) {
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
final hash = Uint8List.fromList(sha256.convert(bytes).bytes);
|
||||
|
||||
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||
if (exsits.isNotEmpty) {
|
||||
duplicated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to get time from EXIF bytes, fallback to current time
|
||||
final createdAt =
|
||||
await getCreationTimeFromBytes(bytes) ?? DateTime.now();
|
||||
|
||||
const type = MediaType.image;
|
||||
|
||||
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||
MediaFilesCompanion(
|
||||
type: const Value(type),
|
||||
createdAt: Value(createdAt),
|
||||
storedFileHash: Value(hash),
|
||||
stored: const Value(true),
|
||||
),
|
||||
);
|
||||
|
||||
if (mediaFile != null) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
await mediaService.storedPath.parent.create(recursive: true);
|
||||
await File(mediaService.storedPath.path).writeAsBytes(bytes);
|
||||
|
||||
await mediaService.calculateAndSaveSize();
|
||||
await mediaService.createThumbnail();
|
||||
unawaited(mediaService.cropTransparentBorders());
|
||||
|
||||
importedCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isImporting = false;
|
||||
_importProgress = 1;
|
||||
});
|
||||
showSnackbar(
|
||||
context,
|
||||
context.lang.importGalleryComplete(
|
||||
importedCount,
|
||||
duplicated,
|
||||
failedCount,
|
||||
),
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPermissionAndLoad() async {
|
||||
|
|
@ -166,6 +267,36 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
|||
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
Future<DateTime?> getCreationTimeFromBytes(Uint8List bytes) async {
|
||||
final dates = <DateTime>[];
|
||||
|
||||
try {
|
||||
final data = await readExifFromBytes(bytes);
|
||||
|
||||
for (final key in data.keys) {
|
||||
if (key.toLowerCase().contains('datetime') || key.contains('Time')) {
|
||||
final time = data[key]?.printable;
|
||||
if (time != null) {
|
||||
try {
|
||||
dates.add(
|
||||
DateFormat('yyyy:MM:dd HH:mm:ss').parse(time),
|
||||
);
|
||||
} catch (e) {
|
||||
// Ignore unparseable formats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore EXIF reading errors
|
||||
}
|
||||
|
||||
if (dates.isEmpty) return null;
|
||||
|
||||
// Return the oldest available date
|
||||
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
void _toggleSelectAll() {
|
||||
setState(() {
|
||||
if (_selectedAssetIds.length == _assets.length) {
|
||||
|
|
@ -248,6 +379,7 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
|||
|
||||
await mediaService.calculateAndSaveSize();
|
||||
await mediaService.createThumbnail();
|
||||
unawaited(mediaService.cropTransparentBorders());
|
||||
|
||||
importedCount++;
|
||||
} else {
|
||||
|
|
@ -314,7 +446,9 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
|||
color: Theme.of(context).cardColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.1),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
|||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.2.25+134
|
||||
version: 0.2.26+135
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
|
|
|||
BIN
test.jpg
Normal file
BIN
test.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 620 B |
Loading…
Reference in a new issue