mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 19:42:12 +00:00
New: Import images from the gallery
This commit is contained in:
parent
358f93979e
commit
a688954d76
28 changed files with 1293 additions and 663 deletions
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
## 0.2.25
|
||||
|
||||
- Improves: Smaller UI changes
|
||||
- New: Import images from the gallery
|
||||
- Improves: Media files are now stored in the "twonly" album
|
||||
- Fix: Migration issue that resulted in a corrupted backup mechanism
|
||||
- Fix: Database issues causing messages to be lost or the database to be corrupted
|
||||
- Fix: Permission view did not disappear after they were granted
|
||||
|
||||
## 0.2.23
|
||||
|
||||
- Improves: Smaller UI changes
|
||||
- Improved: Smaller UI changes
|
||||
- Fix: Some messages were not marked as opened.
|
||||
|
||||
## 0.2.20
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@
|
|||
<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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
|
||||
Keyring.initializeNdkContext(applicationContext)
|
||||
|
||||
MediaStoreChannel.configure(flutterEngine, applicationContext)
|
||||
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
package eu.twonly
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object MediaStoreChannel {
|
||||
private const val CHANNEL = "eu.twonly/mediaStore"
|
||||
|
||||
fun configure(flutterEngine: FlutterEngine, context: Context) {
|
||||
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
if (call.method == "safeFileToDownload") {
|
||||
val arguments = call.arguments<Map<String, String>>() as Map<String, String>
|
||||
val sourceFile = arguments["sourceFile"]
|
||||
if (sourceFile == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
val inputStream = FileInputStream(File(sourceFile))
|
||||
val outputName = File(sourceFile).name.takeIf { it.isNotEmpty() } ?: "memories.zip"
|
||||
|
||||
val savedUri = saveZipToDownloads(context, outputName, inputStream)
|
||||
if (savedUri != null) {
|
||||
result.success(savedUri.toString())
|
||||
} else {
|
||||
result.error("SAVE_FAILED", "Could not save ZIP", null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("EXCEPTION", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveZipToDownloads(
|
||||
context: Context,
|
||||
fileName: String = "archive.zip",
|
||||
sourceStream: InputStream
|
||||
): android.net.Uri? {
|
||||
val resolver = context.contentResolver
|
||||
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/zip")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
} else {
|
||||
MediaStore.Files.getContentUri("external")
|
||||
}
|
||||
|
||||
val uri = resolver.insert(collection, contentValues) ?: return null
|
||||
|
||||
try {
|
||||
resolver.openOutputStream(uri).use { out: OutputStream? ->
|
||||
requireNotNull(out) { "Unable to open output stream" }
|
||||
sourceStream.use { input ->
|
||||
input.copyTo(out)
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
|
||||
resolver.update(uri, done, null, null)
|
||||
}
|
||||
|
||||
return uri
|
||||
} catch (e: Exception) {
|
||||
try { resolver.delete(uri, null, null) } catch (_: Exception) {}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit e0c6a9617a20a8d6bc1ad4c6b9c2e229feb5f37a
|
||||
Subproject commit 72d9bd6320bca1f1d29c6e61c3821fed326c0abe
|
||||
|
|
@ -280,6 +280,9 @@ PODS:
|
|||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- photo_manager (3.9.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- pro_video_editor (0.0.1):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
|
|
@ -355,6 +358,7 @@ DEPENDENCIES:
|
|||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
|
||||
- pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`)
|
||||
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
||||
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
|
||||
|
|
@ -460,6 +464,8 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
photo_manager:
|
||||
:path: ".symlinks/plugins/photo_manager/darwin"
|
||||
pro_video_editor:
|
||||
:path: ".symlinks/plugins/pro_video_editor/ios"
|
||||
restart_app:
|
||||
|
|
@ -536,6 +542,7 @@ SPEC CHECKSUMS:
|
|||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
|
||||
pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ class Routes {
|
|||
static const String settingsStorage = '/settings/storage_data';
|
||||
static const String settingsStorageManage = '/settings/storage_data/manage';
|
||||
static const String settingsStorageImport = '/settings/storage_data/import';
|
||||
static const String settingsStorageImportGallery =
|
||||
'/settings/storage_data/import_gallery';
|
||||
static const String settingsStorageExport = '/settings/storage_data/export';
|
||||
static const String settingsHelp = '/settings/help';
|
||||
static const String settingsHelpFaq = '/settings/help/faq';
|
||||
|
|
|
|||
|
|
@ -185,6 +185,12 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
return rows.map((row) => row.readTable(db.messages).messageId).toList();
|
||||
}
|
||||
|
||||
Future<List<MediaFile>> getMediaByHash(Uint8List hash) async {
|
||||
final query = select(db.mediaFiles)
|
||||
..where((t) => t.storedFileHash.equals(hash));
|
||||
return query.get();
|
||||
}
|
||||
|
||||
Future<Map<MediaType, int>> getStorageStats() async {
|
||||
final rows = await select(mediaFiles).get();
|
||||
final stats = <MediaType, int>{};
|
||||
|
|
|
|||
|
|
@ -1460,6 +1460,12 @@ abstract class AppLocalizations {
|
|||
/// **'Delete for all'**
|
||||
String get deleteOkBtnForAll;
|
||||
|
||||
/// No description provided for @memoriesDeleteSnackbarSuccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1 {Deleted 1 item successfully} other {Deleted {count} items successfully}}'**
|
||||
String memoriesDeleteSnackbarSuccess(num count);
|
||||
|
||||
/// No description provided for @deleteOkBtnForMe.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3505,6 +3511,178 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Yesterday'**
|
||||
String get yesterday;
|
||||
|
||||
/// No description provided for @galleryDisableWarningTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable gallery saving?'**
|
||||
String get galleryDisableWarningTitle;
|
||||
|
||||
/// No description provided for @galleryDisableWarningBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'If you disable this, your media files will not be saved to your gallery and could be permanently lost if twonly is removed or has an issue, as media files are not yet backed up.'**
|
||||
String get galleryDisableWarningBody;
|
||||
|
||||
/// No description provided for @galleryDisableWarningConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable'**
|
||||
String get galleryDisableWarningConfirm;
|
||||
|
||||
/// No description provided for @settingsStorageScanGalleryTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import from Gallery'**
|
||||
String get settingsStorageScanGalleryTitle;
|
||||
|
||||
/// No description provided for @importGalleryDeselectAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deselect all'**
|
||||
String get importGalleryDeselectAll;
|
||||
|
||||
/// No description provided for @importGallerySelectAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select all'**
|
||||
String get importGallerySelectAll;
|
||||
|
||||
/// No description provided for @importGalleryPermissionRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Permission to access your gallery is required to import previous twonly media files.'**
|
||||
String get importGalleryPermissionRequired;
|
||||
|
||||
/// No description provided for @importGalleryPermissionError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'An error occurred while requesting permission: {error}'**
|
||||
String importGalleryPermissionError(Object error);
|
||||
|
||||
/// No description provided for @importGalleryLoadError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to load assets: {error}'**
|
||||
String importGalleryLoadError(Object error);
|
||||
|
||||
/// No description provided for @importGalleryImportingOf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Importing {current} of {total}...'**
|
||||
String importGalleryImportingOf(Object current, Object total);
|
||||
|
||||
/// No description provided for @importGalleryStarting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Starting import...'**
|
||||
String get importGalleryStarting;
|
||||
|
||||
/// No description provided for @importGalleryComplete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import complete: {imported} successfully imported, {duplicated} duplicated and {failed} failed.'**
|
||||
String importGalleryComplete(
|
||||
Object imported,
|
||||
Object duplicated,
|
||||
Object failed,
|
||||
);
|
||||
|
||||
/// No description provided for @importGalleryGrantAccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Grant Access'**
|
||||
String get importGalleryGrantAccess;
|
||||
|
||||
/// No description provided for @importGalleryOpenSettings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open Settings'**
|
||||
String get importGalleryOpenSettings;
|
||||
|
||||
/// No description provided for @importGalleryPermissionDenied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Permission to access gallery denied.'**
|
||||
String get importGalleryPermissionDenied;
|
||||
|
||||
/// No description provided for @importGalleryTryAgain.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Try Again'**
|
||||
String get importGalleryTryAgain;
|
||||
|
||||
/// No description provided for @importGalleryAlbumNotFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"twonly\" album not found'**
|
||||
String get importGalleryAlbumNotFound;
|
||||
|
||||
/// No description provided for @importGalleryAlbumNotFoundDesc.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'If you don\'t have this album yet, you can also create it to import photos into twonly.'**
|
||||
String get importGalleryAlbumNotFoundDesc;
|
||||
|
||||
/// No description provided for @importGalleryNoImagesFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No images found'**
|
||||
String get importGalleryNoImagesFound;
|
||||
|
||||
/// No description provided for @importGalleryNoImagesFoundDesc.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'There are no images on your device.'**
|
||||
String get importGalleryNoImagesFoundDesc;
|
||||
|
||||
/// No description provided for @importGalleryShowAllImages.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show all images'**
|
||||
String get importGalleryShowAllImages;
|
||||
|
||||
/// No description provided for @importGalleryShowTwonlyAlbum.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show twonly album'**
|
||||
String get importGalleryShowTwonlyAlbum;
|
||||
|
||||
/// No description provided for @importGalleryToggleDescAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Viewing all images on your device.'**
|
||||
String get importGalleryToggleDescAll;
|
||||
|
||||
/// No description provided for @importGalleryToggleDescTwonly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Viewing the \"twonly\" album.'**
|
||||
String get importGalleryToggleDescTwonly;
|
||||
|
||||
/// No description provided for @importGalleryFilterTwonly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Only show the twonly-Album'**
|
||||
String get importGalleryFilterTwonly;
|
||||
|
||||
/// No description provided for @importGalleryRefresh.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh'**
|
||||
String get importGalleryRefresh;
|
||||
|
||||
/// No description provided for @importGallerySelectToImport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select items to import'**
|
||||
String get importGallerySelectToImport;
|
||||
|
||||
/// No description provided for @importGalleryImportCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Import 1 item} other{Import {count} items}}'**
|
||||
String importGalleryImportCount(num count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -752,6 +752,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get deleteOkBtnForAll => 'Für alle löschen';
|
||||
|
||||
@override
|
||||
String memoriesDeleteSnackbarSuccess(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Elemente erfolgreich gelöscht',
|
||||
one: '1 Element erfolgreich gelöscht',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deleteOkBtnForMe => 'Für mich löschen';
|
||||
|
||||
|
|
@ -1994,4 +2005,115 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get yesterday => 'Gestern';
|
||||
|
||||
@override
|
||||
String get galleryDisableWarningTitle => 'Galeriespeicherung deaktivieren?';
|
||||
|
||||
@override
|
||||
String get galleryDisableWarningBody =>
|
||||
'Wenn du dies deaktivierst, werden deine Mediendateien nicht in deiner Galerie gespeichert und könnten dauerhaft verloren gehen, wenn twonly deinstalliert wird oder ein Problem auftritt, da Mediendateien noch nicht in Backups enthalten sind.';
|
||||
|
||||
@override
|
||||
String get galleryDisableWarningConfirm => 'Deaktivieren';
|
||||
|
||||
@override
|
||||
String get settingsStorageScanGalleryTitle => 'Aus Galerie importieren';
|
||||
|
||||
@override
|
||||
String get importGalleryDeselectAll => 'Alle abwählen';
|
||||
|
||||
@override
|
||||
String get importGallerySelectAll => 'Alle auswählen';
|
||||
|
||||
@override
|
||||
String get importGalleryPermissionRequired =>
|
||||
'Zugriff auf deine Galerie ist erforderlich, um frühere twonly-Mediendateien zu importieren.';
|
||||
|
||||
@override
|
||||
String importGalleryPermissionError(Object error) {
|
||||
return 'Beim Anfordern der Berechtigung ist ein Fehler aufgetreten: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String importGalleryLoadError(Object error) {
|
||||
return 'Laden der Medien fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String importGalleryImportingOf(Object current, Object total) {
|
||||
return '$current von $total wird importiert...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get importGalleryStarting => 'Import wird gestartet...';
|
||||
|
||||
@override
|
||||
String importGalleryComplete(
|
||||
Object imported,
|
||||
Object duplicated,
|
||||
Object failed,
|
||||
) {
|
||||
return 'Import abgeschlossen: $imported erfolgreich importiert, $duplicated Duplikate und $failed fehlgeschlagen.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get importGalleryGrantAccess => 'Zugriff erlauben';
|
||||
|
||||
@override
|
||||
String get importGalleryOpenSettings => 'Einstellungen öffnen';
|
||||
|
||||
@override
|
||||
String get importGalleryPermissionDenied => 'Zugriff auf Galerie verweigert.';
|
||||
|
||||
@override
|
||||
String get importGalleryTryAgain => 'Erneut versuchen';
|
||||
|
||||
@override
|
||||
String get importGalleryAlbumNotFound => '\"twonly\"-Album nicht gefunden';
|
||||
|
||||
@override
|
||||
String get importGalleryAlbumNotFoundDesc =>
|
||||
'Falls du dieses Album noch nicht hast, kannst du es auch erstellen, um Fotos in twonly zu importieren.';
|
||||
|
||||
@override
|
||||
String get importGalleryNoImagesFound => 'Keine Bilder gefunden';
|
||||
|
||||
@override
|
||||
String get importGalleryNoImagesFoundDesc =>
|
||||
'Es befinden sich keine Bilder auf deinem Gerät.';
|
||||
|
||||
@override
|
||||
String get importGalleryShowAllImages => 'Alle Bilder anzeigen';
|
||||
|
||||
@override
|
||||
String get importGalleryShowTwonlyAlbum => 'twonly-Album anzeigen';
|
||||
|
||||
@override
|
||||
String get importGalleryToggleDescAll =>
|
||||
'Es werden alle Bilder auf deinem Gerät angezeigt.';
|
||||
|
||||
@override
|
||||
String get importGalleryToggleDescTwonly =>
|
||||
'Es wird das \"twonly\"-Album angezeigt.';
|
||||
|
||||
@override
|
||||
String get importGalleryFilterTwonly => 'Nur das twonly-Album anzeigen';
|
||||
|
||||
@override
|
||||
String get importGalleryRefresh => 'Aktualisieren';
|
||||
|
||||
@override
|
||||
String get importGallerySelectToImport =>
|
||||
'Elemente zum Importieren auswählen';
|
||||
|
||||
@override
|
||||
String importGalleryImportCount(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Elemente importieren',
|
||||
one: '1 Element importieren',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -746,6 +746,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get deleteOkBtnForAll => 'Delete for all';
|
||||
|
||||
@override
|
||||
String memoriesDeleteSnackbarSuccess(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Deleted $count items successfully',
|
||||
one: 'Deleted 1 item successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deleteOkBtnForMe => 'Delete for me';
|
||||
|
||||
|
|
@ -1978,4 +1989,113 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get yesterday => 'Yesterday';
|
||||
|
||||
@override
|
||||
String get galleryDisableWarningTitle => 'Disable gallery saving?';
|
||||
|
||||
@override
|
||||
String get galleryDisableWarningBody =>
|
||||
'If you disable this, your media files will not be saved to your gallery and could be permanently lost if twonly is removed or has an issue, as media files are not yet backed up.';
|
||||
|
||||
@override
|
||||
String get galleryDisableWarningConfirm => 'Disable';
|
||||
|
||||
@override
|
||||
String get settingsStorageScanGalleryTitle => 'Import from Gallery';
|
||||
|
||||
@override
|
||||
String get importGalleryDeselectAll => 'Deselect all';
|
||||
|
||||
@override
|
||||
String get importGallerySelectAll => 'Select all';
|
||||
|
||||
@override
|
||||
String get importGalleryPermissionRequired =>
|
||||
'Permission to access your gallery is required to import previous twonly media files.';
|
||||
|
||||
@override
|
||||
String importGalleryPermissionError(Object error) {
|
||||
return 'An error occurred while requesting permission: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String importGalleryLoadError(Object error) {
|
||||
return 'Failed to load assets: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String importGalleryImportingOf(Object current, Object total) {
|
||||
return 'Importing $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get importGalleryStarting => 'Starting import...';
|
||||
|
||||
@override
|
||||
String importGalleryComplete(
|
||||
Object imported,
|
||||
Object duplicated,
|
||||
Object failed,
|
||||
) {
|
||||
return 'Import complete: $imported successfully imported, $duplicated duplicated and $failed failed.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get importGalleryGrantAccess => 'Grant Access';
|
||||
|
||||
@override
|
||||
String get importGalleryOpenSettings => 'Open Settings';
|
||||
|
||||
@override
|
||||
String get importGalleryPermissionDenied =>
|
||||
'Permission to access gallery denied.';
|
||||
|
||||
@override
|
||||
String get importGalleryTryAgain => 'Try Again';
|
||||
|
||||
@override
|
||||
String get importGalleryAlbumNotFound => '\"twonly\" album not found';
|
||||
|
||||
@override
|
||||
String get importGalleryAlbumNotFoundDesc =>
|
||||
'If you don\'t have this album yet, you can also create it to import photos into twonly.';
|
||||
|
||||
@override
|
||||
String get importGalleryNoImagesFound => 'No images found';
|
||||
|
||||
@override
|
||||
String get importGalleryNoImagesFoundDesc =>
|
||||
'There are no images on your device.';
|
||||
|
||||
@override
|
||||
String get importGalleryShowAllImages => 'Show all images';
|
||||
|
||||
@override
|
||||
String get importGalleryShowTwonlyAlbum => 'Show twonly album';
|
||||
|
||||
@override
|
||||
String get importGalleryToggleDescAll => 'Viewing all images on your device.';
|
||||
|
||||
@override
|
||||
String get importGalleryToggleDescTwonly => 'Viewing the \"twonly\" album.';
|
||||
|
||||
@override
|
||||
String get importGalleryFilterTwonly => 'Only show the twonly-Album';
|
||||
|
||||
@override
|
||||
String get importGalleryRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String get importGallerySelectToImport => 'Select items to import';
|
||||
|
||||
@override
|
||||
String importGalleryImportCount(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Import $count items',
|
||||
one: 'Import 1 item',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ class UserData {
|
|||
required this.currentSetupPage,
|
||||
required this.appVersion,
|
||||
});
|
||||
factory UserData.fromJson(Map<String, dynamic> json) => _$UserDataFromJson(json);
|
||||
factory UserData.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserDataFromJson(json);
|
||||
|
||||
final int userId;
|
||||
|
||||
|
|
@ -86,8 +87,8 @@ class UserData {
|
|||
|
||||
Map<String, List<String>>? autoDownloadOptions;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool storeMediaFilesInGallery = false;
|
||||
@JsonKey(defaultValue: true)
|
||||
bool storeMediaFilesInGallery = true;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool autoStoreAllSendUnlimitedMediaFiles = false;
|
||||
|
|
@ -189,7 +190,8 @@ class TwonlySafeBackup {
|
|||
required this.backupId,
|
||||
required this.encryptionKey,
|
||||
});
|
||||
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) => _$TwonlySafeBackupFromJson(json);
|
||||
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) =>
|
||||
_$TwonlySafeBackupFromJson(json);
|
||||
|
||||
int lastBackupSize = 0;
|
||||
LastBackupUploadState backupUploadState = LastBackupUploadState.none;
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ import 'package:twonly/src/visual/views/settings/backup/backup_setup.view.dart';
|
|||
import 'package:twonly/src/visual/views/settings/chat/chat_reactions.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/chat/chat_settings.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/data_and_storage.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/data_and_storage/export_media.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/data_and_storage/import_media.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/developer/developer.view.dart';
|
||||
|
|
@ -225,12 +224,8 @@ final routerProvider = GoRouter(
|
|||
builder: (context, state) => const ManageStorageView(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'import',
|
||||
builder: (context, state) => const ImportMediaView(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'export',
|
||||
builder: (context, state) => const ExportMediaView(),
|
||||
path: 'import_gallery',
|
||||
builder: (context, state) => const ImportFromGalleryView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ Future<String?> saveImageToGallery(Uint8List imageBytes) async {
|
|||
imageBytes,
|
||||
quality: 100,
|
||||
);
|
||||
final hasAccess = await Gal.hasAccess();
|
||||
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||
if (!hasAccess) {
|
||||
await Gal.requestAccess();
|
||||
await Gal.requestAccess(toAlbum: true);
|
||||
}
|
||||
try {
|
||||
await Gal.putImageBytes(jpgImages);
|
||||
await Gal.putImageBytes(jpgImages, album: 'twonly');
|
||||
return null;
|
||||
} on GalException catch (e) {
|
||||
Log.error(e);
|
||||
|
|
@ -52,12 +52,12 @@ Future<String?> saveImageToGallery(Uint8List imageBytes) async {
|
|||
}
|
||||
|
||||
Future<String?> saveVideoToGallery(String videoPath) async {
|
||||
final hasAccess = await Gal.hasAccess();
|
||||
final hasAccess = await Gal.hasAccess(toAlbum: true);
|
||||
if (!hasAccess) {
|
||||
await Gal.requestAccess();
|
||||
await Gal.requestAccess(toAlbum: true);
|
||||
}
|
||||
try {
|
||||
await Gal.putVideo(videoPath);
|
||||
await Gal.putVideo(videoPath, album: 'twonly');
|
||||
return null;
|
||||
} on GalException catch (e) {
|
||||
Log.error(e);
|
||||
|
|
|
|||
77
lib/src/visual/components/selectable_thumbnail.comp.dart
Normal file
77
lib/src/visual/components/selectable_thumbnail.comp.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
class SelectableThumbnailComp extends StatelessWidget {
|
||||
const SelectableThumbnailComp({
|
||||
required this.child,
|
||||
required this.isSelected,
|
||||
this.selectionMode = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool isSelected;
|
||||
final bool selectionMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? context.color.primary : Colors.transparent,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
margin: EdgeInsets.all(isSelected ? 4 : 0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
isSelected ? 12 : 0,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
if (selectionMode)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.color.primary
|
||||
: Colors.black38,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
)
|
||||
: const SizedBox(width: 14, height: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,8 @@ class UserDiscoveryContactSettingsComp extends StatelessWidget {
|
|||
icon: FontAwesomeIcons.usersViewfinder,
|
||||
text: context.lang.userDiscoverySettingsTitle,
|
||||
onTap: () => context.navPush(const UserDiscoverySettingsView()),
|
||||
subtitle: !contact.userDiscoveryExcluded &&
|
||||
subtitle:
|
||||
!contact.userDiscoveryExcluded &&
|
||||
contact.mediaSendCounter <
|
||||
userService.currentUser.requiredSendImages
|
||||
? Text(
|
||||
|
|
@ -66,7 +67,7 @@ class UserDiscoveryContactSettingsComp extends StatelessWidget {
|
|||
: null,
|
||||
trailing: Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
child: Switch.adaptive(
|
||||
value: !contact.userDiscoveryExcluded,
|
||||
onChanged: (a) async {
|
||||
await UserDiscoveryService.changeExclusionForContact(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
||||
|
||||
class MemoriesThumbnailComp extends StatefulWidget {
|
||||
const MemoriesThumbnailComp({
|
||||
required this.galleryItem,
|
||||
|
|
@ -166,31 +165,9 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
|||
scale: _scaleAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _scaleController,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
? context.color.primary
|
||||
: Colors.transparent,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
margin: EdgeInsets.all(widget.isSelected ? 4 : 0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.isSelected ? 12 : 0,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SelectableThumbnailComp(
|
||||
isSelected: widget.isSelected,
|
||||
selectionMode: widget.selectionMode,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
|
@ -215,7 +192,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
|||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (isVideo)
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
|
|
@ -229,37 +205,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
|||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.selectionMode)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
? context.color.primary
|
||||
: Colors.black38,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: widget.isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
)
|
||||
: const SizedBox(width: 14, height: 14),
|
||||
),
|
||||
),
|
||||
|
||||
if (media.mediaFile.isFavorite)
|
||||
const Positioned(
|
||||
bottom: 6,
|
||||
|
|
@ -279,7 +224,6 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
'Deleted $count items successfully',
|
||||
context.lang.memoriesDeleteSnackbarSuccess(count),
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
}
|
||||
|
|
@ -354,7 +354,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
controller: _scrollController,
|
||||
labelBuilder: (offset) {
|
||||
final state = _service.currentState;
|
||||
if (state.isEmpty) return null;
|
||||
if (state.isEmpty || state.months.isEmpty) return null;
|
||||
|
||||
// Simple heuristic to find month by offset
|
||||
double currentOffset = 56;
|
||||
|
|
@ -487,8 +487,10 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
),
|
||||
),
|
||||
],
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: 32),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 150,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class _AppearanceViewState extends State<AppearanceView> {
|
|||
ListTile(
|
||||
title: Text(context.lang.contactUsShortcut),
|
||||
onTap: toggleShowFeedbackIcon,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: !userService.currentUser.showFeedbackShortcut,
|
||||
onChanged: (a) => toggleShowFeedbackIcon(),
|
||||
),
|
||||
|
|
@ -123,7 +123,7 @@ class _AppearanceViewState extends State<AppearanceView> {
|
|||
ListTile(
|
||||
title: Text(context.lang.startWithCameraOpen),
|
||||
onTap: toggleStartWithCameraOpen,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.startWithCameraOpen,
|
||||
onChanged: (a) => toggleStartWithCameraOpen(),
|
||||
),
|
||||
|
|
@ -131,7 +131,7 @@ class _AppearanceViewState extends State<AppearanceView> {
|
|||
ListTile(
|
||||
title: Text(context.lang.showImagePreviewWhenSending),
|
||||
onTap: toggleShowImagePreviewWhenSending,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value:
|
||||
userService.currentUser.showShowImagePreviewWhenSending,
|
||||
onChanged: (a) => toggleShowImagePreviewWhenSending(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -39,6 +37,35 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
|
|||
}
|
||||
|
||||
Future<void> toggleStoreInGallery() async {
|
||||
final currentlyEnabled = userService.currentUser.storeMediaFilesInGallery;
|
||||
if (currentlyEnabled) {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.lang.galleryDisableWarningTitle),
|
||||
content: Text(context.lang.galleryDisableWarningBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(context.lang.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(
|
||||
context.lang.galleryDisableWarningConfirm,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirm != true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await UserService.update((u) {
|
||||
u.storeMediaFilesInGallery = !u.storeMediaFilesInGallery;
|
||||
});
|
||||
|
|
@ -83,11 +110,8 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
|
|||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(context.lang.settingsStorageDataStoreInGTitle),
|
||||
subtitle: Text(
|
||||
context.lang.settingsStorageDataStoreInGSubtitle,
|
||||
),
|
||||
onTap: toggleStoreInGallery,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.storeMediaFilesInGallery,
|
||||
onChanged: (a) => toggleStoreInGallery(),
|
||||
),
|
||||
|
|
@ -99,26 +123,18 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
|
|||
style: const TextStyle(fontSize: 9),
|
||||
),
|
||||
onTap: toggleAutoStoreMediaFiles,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService
|
||||
.currentUser
|
||||
.autoStoreAllSendUnlimitedMediaFiles,
|
||||
onChanged: (a) => toggleAutoStoreMediaFiles(),
|
||||
),
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
ListTile(
|
||||
title: Text(
|
||||
context.lang.exportMemories,
|
||||
),
|
||||
onTap: () => context.push(Routes.settingsStorageExport),
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
ListTile(
|
||||
title: Text(
|
||||
context.lang.importMemories,
|
||||
),
|
||||
onTap: () => context.push(Routes.settingsStorageImport),
|
||||
title: Text(context.lang.settingsStorageScanGalleryTitle),
|
||||
onTap: () {
|
||||
context.push(Routes.settingsStorageImportGallery);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -1,213 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
||||
class AndroidMediaStore {
|
||||
static const androidMediaStoreChannel = MethodChannel('eu.twonly/mediaStore');
|
||||
|
||||
static Future<bool> safeFileToDownload(File sourceFile) async {
|
||||
try {
|
||||
Log.info('Storing $sourceFile');
|
||||
final storedPath = (
|
||||
await androidMediaStoreChannel.invokeMethod('safeFileToDownload', {
|
||||
'sourceFile': sourceFile.path,
|
||||
}),
|
||||
);
|
||||
Log.info(storedPath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExportMediaView extends StatefulWidget {
|
||||
const ExportMediaView({super.key});
|
||||
|
||||
@override
|
||||
State<ExportMediaView> createState() => _ExportMediaViewState();
|
||||
}
|
||||
|
||||
class _ExportMediaViewState extends State<ExportMediaView> {
|
||||
double _progress = 0;
|
||||
String? _status;
|
||||
File? _zipFile;
|
||||
bool _isZipping = false;
|
||||
bool _zipSaved = false;
|
||||
bool _isStoring = false;
|
||||
|
||||
Directory _mediaFolder() {
|
||||
final dir = MediaFileService.buildDirectoryPath(
|
||||
'stored',
|
||||
AppEnvironment.supportDir,
|
||||
);
|
||||
if (!dir.existsSync()) dir.createSync(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
Future<void> _createZipFromMediaFolder() async {
|
||||
setState(() {
|
||||
_isZipping = true;
|
||||
_progress = 0.0;
|
||||
_status = 'Preparing...';
|
||||
_zipFile = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final folder = _mediaFolder();
|
||||
final allFiles = folder
|
||||
.listSync(recursive: true)
|
||||
.whereType<File>()
|
||||
.toList();
|
||||
|
||||
final mediaFiles = allFiles.where((f) {
|
||||
final name = p.basename(f.path).toLowerCase();
|
||||
if (name.contains('thumbnail')) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (mediaFiles.isEmpty) {
|
||||
setState(() {
|
||||
_status = 'No memories found.';
|
||||
_isZipping = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// compute total bytes for progress
|
||||
var totalBytes = 0;
|
||||
for (final f in mediaFiles) {
|
||||
totalBytes += await f.length();
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final zipPath = p.join(
|
||||
tempDir.path,
|
||||
'memories.zip',
|
||||
);
|
||||
final encoder = ZipFileEncoder()..create(zipPath);
|
||||
|
||||
var processedBytes = 0;
|
||||
for (final f in mediaFiles) {
|
||||
final relative = p.relative(f.path, from: folder.path);
|
||||
setState(() {
|
||||
_status = 'Adding $relative';
|
||||
});
|
||||
|
||||
await encoder.addFile(f, relative);
|
||||
|
||||
processedBytes += await f.length();
|
||||
setState(() {
|
||||
_progress = totalBytes > 0 ? processedBytes / totalBytes : 0.0;
|
||||
});
|
||||
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 10),
|
||||
);
|
||||
}
|
||||
|
||||
await encoder.close();
|
||||
|
||||
setState(() {
|
||||
_zipFile = File(zipPath);
|
||||
_status = 'ZIP created: ${p.basename(zipPath)}';
|
||||
_progress = 1.0;
|
||||
_isZipping = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Error: $e';
|
||||
_isZipping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveZip() async {
|
||||
if (_zipFile == null) return;
|
||||
setState(() {
|
||||
_isStoring = true;
|
||||
});
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
if (!await AndroidMediaStore.safeFileToDownload(_zipFile!)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
final outputFile = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Save your memories to desired location',
|
||||
fileName: p.basename(_zipFile!.path),
|
||||
bytes: _zipFile!.readAsBytesSync(),
|
||||
);
|
||||
if (outputFile == null) return;
|
||||
}
|
||||
_zipSaved = true;
|
||||
_isStoring = false;
|
||||
_status = 'ZIP stored: ${p.basename(_zipFile!.path)}';
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
_isStoring = false;
|
||||
setState(() => _status = 'Save failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Export memories'),
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Here, you can export all you memories.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isZipping || _zipFile != null)
|
||||
LinearProgressIndicator(
|
||||
value: _isZipping ? _progress : (_zipFile != null ? 1.0 : 0.0),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_status != null)
|
||||
Text(
|
||||
_status!,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_zipFile == null)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.archive),
|
||||
label: Text(
|
||||
_isZipping ? 'Zipping...' : 'Create ZIP from mediafiles',
|
||||
),
|
||||
onPressed: _isZipping ? null : _createZipFromMediaFolder,
|
||||
)
|
||||
else if (!_zipSaved)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save_alt),
|
||||
label: const Text('Save ZIP'),
|
||||
onPressed: (_zipFile != null && !_isZipping && !_isStoring)
|
||||
? _saveZip
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,627 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:exif/exif.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
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/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart' show ShortCutsExtension, sha256File;
|
||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
import 'package:twonly/src/visual/themes/light.dart';
|
||||
|
||||
class ImportFromGalleryView extends StatefulWidget {
|
||||
const ImportFromGalleryView({super.key});
|
||||
|
||||
@override
|
||||
State<ImportFromGalleryView> createState() => _ImportFromGalleryViewState();
|
||||
}
|
||||
|
||||
class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||
bool _isLoading = true;
|
||||
bool _isImporting = false;
|
||||
String? _errorMessage;
|
||||
bool _hasPermission = false;
|
||||
List<AssetEntity> _assets = [];
|
||||
final Set<String> _selectedAssetIds = {};
|
||||
bool _showingAllImages = false;
|
||||
double _importProgress = 0;
|
||||
String _importStatus = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkPermissionAndLoad();
|
||||
}
|
||||
|
||||
Future<void> _checkPermissionAndLoad() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final ps = await PhotoManager.requestPermissionExtend();
|
||||
if (ps.isAuth || ps.hasAccess) {
|
||||
setState(() {
|
||||
_hasPermission = true;
|
||||
});
|
||||
await _loadMedia();
|
||||
} else {
|
||||
setState(() {
|
||||
_hasPermission = false;
|
||||
_isLoading = false;
|
||||
_errorMessage = context.lang.importGalleryPermissionRequired;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = context.lang.importGalleryPermissionError(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMedia() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
try {
|
||||
if (_showingAllImages) {
|
||||
final albums = await PhotoManager.getAssetPathList(onlyAll: true);
|
||||
if (albums.isEmpty) {
|
||||
setState(() {
|
||||
_assets = [];
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final recentAlbum = albums.first;
|
||||
final count = await recentAlbum.assetCountAsync;
|
||||
final assets = await recentAlbum.getAssetListRange(
|
||||
start: 0,
|
||||
end: count,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_assets = assets;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
final albums = await PhotoManager.getAssetPathList();
|
||||
|
||||
AssetPathEntity? twonlyAlbum;
|
||||
for (final album in albums) {
|
||||
if (album.name.toLowerCase() == 'twonly') {
|
||||
twonlyAlbum = album;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (twonlyAlbum == null) {
|
||||
setState(() {
|
||||
_assets = [];
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final count = await twonlyAlbum.assetCountAsync;
|
||||
final assets = await twonlyAlbum.getAssetListRange(
|
||||
start: 0,
|
||||
end: count,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_assets = assets;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = context.lang.importGalleryLoadError(e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<DateTime> getImageCreationTime(AssetEntity asset, File file) async {
|
||||
final dates = <DateTime>[asset.createDateTime];
|
||||
|
||||
if (!file.existsSync()) {
|
||||
return asset.createDateTime;
|
||||
}
|
||||
|
||||
// Read the EXIF data
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
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
|
||||
}
|
||||
|
||||
// Return the oldest available date
|
||||
return dates.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
void _toggleSelectAll() {
|
||||
setState(() {
|
||||
if (_selectedAssetIds.length == _assets.length) {
|
||||
_selectedAssetIds.clear();
|
||||
} else {
|
||||
_selectedAssetIds.clear();
|
||||
for (final asset in _assets) {
|
||||
_selectedAssetIds.add(asset.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startImport() async {
|
||||
if (_selectedAssetIds.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isImporting = true;
|
||||
_importProgress = 0;
|
||||
_importStatus = context.lang.importGalleryStarting;
|
||||
});
|
||||
|
||||
final selectedAssets = _assets
|
||||
.where((a) => _selectedAssetIds.contains(a.id))
|
||||
.toList();
|
||||
final total = selectedAssets.length;
|
||||
var importedCount = 0;
|
||||
var duplicated = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
for (final asset in selectedAssets) {
|
||||
try {
|
||||
setState(() {
|
||||
_importStatus = context.lang.importGalleryImportingOf(
|
||||
importedCount + failedCount + 1,
|
||||
total,
|
||||
);
|
||||
_importProgress = (importedCount + failedCount) / total;
|
||||
});
|
||||
|
||||
final file = await asset.file;
|
||||
if (file == null || !file.existsSync()) {
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
final hash = Uint8List.fromList(await sha256File(file));
|
||||
|
||||
final exsits = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||
if (exsits.isNotEmpty) {
|
||||
duplicated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
final createdAt = await getImageCreationTime(asset, file);
|
||||
|
||||
// Determine media type
|
||||
late final MediaType type;
|
||||
if (asset.type == AssetType.video) {
|
||||
type = MediaType.video;
|
||||
} else if (file.path.toLowerCase().endsWith('.gif')) {
|
||||
type = MediaType.gif;
|
||||
} else {
|
||||
type = MediaType.image;
|
||||
}
|
||||
|
||||
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||
MediaFilesCompanion(
|
||||
type: 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.copy(mediaService.storedPath.path);
|
||||
|
||||
await mediaService.calculateAndSaveSize();
|
||||
await mediaService.createThumbnail();
|
||||
|
||||
importedCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImporting = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
showSnackbar(
|
||||
context,
|
||||
context.lang.importGalleryComplete(
|
||||
importedCount,
|
||||
duplicated,
|
||||
failedCount,
|
||||
),
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAllSelected =
|
||||
_assets.isNotEmpty && _selectedAssetIds.length == _assets.length;
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
appBar: AppBar(
|
||||
title: Text(context.lang.settingsStorageScanGalleryTitle),
|
||||
actions: [
|
||||
if (!_isLoading && _assets.isNotEmpty && !_isImporting)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isAllSelected ? Icons.deselect : Icons.select_all,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: isAllSelected
|
||||
? context.lang.importGalleryDeselectAll
|
||||
: context.lang.importGallerySelectAll,
|
||||
onPressed: _toggleSelectAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_isLoading && !_isImporting && _hasPermission)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.lang.importGalleryFilterTwonly,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: !_showingAllImages,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showingAllImages = !value;
|
||||
_selectedAssetIds.clear();
|
||||
});
|
||||
_loadMedia();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
if (_isImporting) _buildImportingOverlay(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar:
|
||||
_assets.isEmpty ||
|
||||
_isLoading ||
|
||||
_isImporting ||
|
||||
_selectedAssetIds.isEmpty
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ElevatedButton(
|
||||
style: primaryColorButtonStyle,
|
||||
onPressed: _startImport,
|
||||
child: Text(
|
||||
_selectedAssetIds.isEmpty
|
||||
? context.lang.importGallerySelectToImport
|
||||
: context.lang.importGalleryImportCount(
|
||||
_selectedAssetIds.length,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (!_hasPermission) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage ?? context.lang.importGalleryPermissionDenied,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _checkPermissionAndLoad,
|
||||
child: Text(context.lang.importGalleryGrantAccess),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: PhotoManager.openSetting,
|
||||
child: Text(context.lang.importGalleryOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _checkPermissionAndLoad,
|
||||
child: Text(context.lang.importGalleryTryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_assets.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_showingAllImages
|
||||
? context.lang.importGalleryNoImagesFound
|
||||
: context.lang.importGalleryAlbumNotFound,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_showingAllImages
|
||||
? context.lang.importGalleryNoImagesFoundDesc
|
||||
: context.lang.importGalleryAlbumNotFoundDesc,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _checkPermissionAndLoad,
|
||||
child: Text(context.lang.importGalleryRefresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
MediaQuery.of(context).padding.bottom + 80,
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
childAspectRatio: 9 / 16,
|
||||
),
|
||||
itemCount: _assets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final asset = _assets[index];
|
||||
final isSelected = _selectedAssetIds.contains(asset.id);
|
||||
return GalleryThumbnailWidget(
|
||||
asset: asset,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedAssetIds.remove(asset.id);
|
||||
} else {
|
||||
_selectedAssetIds.add(asset.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImportingOverlay() {
|
||||
return ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_importStatus,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(value: _importProgress),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GalleryThumbnailWidget extends StatefulWidget {
|
||||
const GalleryThumbnailWidget({
|
||||
required this.asset,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AssetEntity asset;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<GalleryThumbnailWidget> createState() => _GalleryThumbnailWidgetState();
|
||||
}
|
||||
|
||||
class _GalleryThumbnailWidgetState extends State<GalleryThumbnailWidget> {
|
||||
late final Future<Uint8List?> _thumbnailFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_thumbnailFuture = widget.asset.thumbnailData;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: SelectableThumbnailComp(
|
||||
isSelected: widget.isSelected,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FutureBuilder<Uint8List?>(
|
||||
future: _thumbnailFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done &&
|
||||
snapshot.data != null) {
|
||||
return Image.memory(
|
||||
snapshot.data!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
return ColoredBox(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.asset.type == AssetType.video)
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black54, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
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/mediafiles/mediafile.service.dart';
|
||||
|
||||
class ImportMediaView extends StatefulWidget {
|
||||
const ImportMediaView({super.key});
|
||||
|
||||
@override
|
||||
State<ImportMediaView> createState() => _ImportMediaViewState();
|
||||
}
|
||||
|
||||
class _ImportMediaViewState extends State<ImportMediaView> {
|
||||
double _progress = 0;
|
||||
String? _status;
|
||||
File? _zipFile;
|
||||
bool _isProcessing = false;
|
||||
|
||||
Future<void> _pickAndImportZip() async {
|
||||
setState(() {
|
||||
_status = null;
|
||||
_progress = 0;
|
||||
_zipFile = null;
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
setState(() {
|
||||
_status = 'No file selected.';
|
||||
_isProcessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final pickedPath = result.files.single.path;
|
||||
if (pickedPath == null) {
|
||||
setState(() {
|
||||
_status = 'Selected file has no path.';
|
||||
_isProcessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final pickedFile = File(pickedPath);
|
||||
if (!pickedFile.existsSync()) {
|
||||
setState(() {
|
||||
_status = 'Selected file does not exist.';
|
||||
_isProcessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_zipFile = pickedFile;
|
||||
_status = 'Selected ${p.basename(pickedPath)}';
|
||||
});
|
||||
|
||||
await _extractZipToMediaFolder(pickedFile);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Error: $e';
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractZipToMediaFolder(File zipFile) async {
|
||||
setState(() {
|
||||
_status = 'Reading archive...';
|
||||
_progress = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
final stream = InputFileStream(zipFile.path);
|
||||
|
||||
final archive = ZipDecoder().decodeStream(stream);
|
||||
|
||||
// Optionally: compute total entries to show progress
|
||||
final entries = archive.where((e) => e.isFile).toList();
|
||||
final total = entries.length;
|
||||
var processed = 0;
|
||||
|
||||
for (final file in entries) {
|
||||
if (!file.isFile || file.isSymbolicLink) continue;
|
||||
|
||||
final extSplit = file.name.split('.');
|
||||
if (extSplit.isEmpty) continue;
|
||||
final ext = extSplit.last;
|
||||
|
||||
late MediaType type;
|
||||
switch (ext) {
|
||||
case 'webp':
|
||||
type = MediaType.image;
|
||||
case 'mp4':
|
||||
type = MediaType.video;
|
||||
case 'gif':
|
||||
type = MediaType.gif;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||
MediaFilesCompanion(
|
||||
type: Value(type),
|
||||
createdAt: Value(file.lastModDateTime),
|
||||
stored: const Value(true),
|
||||
),
|
||||
);
|
||||
final mediaService = MediaFileService(mediaFile!);
|
||||
await mediaService.storedPath.writeAsBytes(file.content);
|
||||
|
||||
processed++;
|
||||
setState(() {
|
||||
_progress = total > 0 ? processed / total : 0;
|
||||
_status = 'Imported ${file.name}';
|
||||
});
|
||||
|
||||
// allow UI to update for large archives
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = 'Import complete. ${entries.length} entries processed.';
|
||||
_isProcessing = false;
|
||||
_progress = 1;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Extraction failed: $e';
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Import memories'),
|
||||
),
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Here, you can import exported memories.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_isProcessing || _zipFile != null)
|
||||
LinearProgressIndicator(
|
||||
value: _isProcessing
|
||||
? _progress
|
||||
: (_zipFile != null ? 1.0 : 0.0),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_status != null)
|
||||
Text(
|
||||
_status!,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.file_upload),
|
||||
label: Text(
|
||||
_isProcessing
|
||||
? 'Processing...'
|
||||
: 'Select memories.zip to import',
|
||||
),
|
||||
onPressed: _isProcessing ? null : _pickAndImportZip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -283,7 +283,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
ListTile(
|
||||
title: const Text('Show Developer Settings'),
|
||||
onTap: toggleDeveloperSettings,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.isDeveloper,
|
||||
onChanged: (_) => toggleDeveloperSettings(),
|
||||
),
|
||||
|
|
@ -291,7 +291,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
ListTile(
|
||||
title: const Text('Enable Database Logging'),
|
||||
onTap: toggleDatabaseLogging,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.enableDatabaseLogging,
|
||||
onChanged: (_) => toggleDatabaseLogging(),
|
||||
),
|
||||
|
|
@ -326,7 +326,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(dbCopyPath)],
|
||||
text: 'Twonly Database',
|
||||
text: 'twonly Database',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -338,7 +338,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
ListTile(
|
||||
title: const Text('Toggle Video Stabilization'),
|
||||
onTap: toggleVideoStabilization,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.videoStabilizationEnabled,
|
||||
onChanged: (a) => toggleVideoStabilization(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class _HelpViewState extends State<HelpView> {
|
|||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
onTap: toggleAllowErrorTrackingViaSentry,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.allowErrorTrackingViaSentry,
|
||||
onChanged: (a) => toggleAllowErrorTrackingViaSentry(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class _PrivacyViewState extends State<PrivacyView> {
|
|||
title: Text(context.lang.settingsTypingIndication),
|
||||
subtitle: Text(context.lang.settingsTypingIndicationSubtitle),
|
||||
onTap: toggleTypingIndicators,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.typingIndicators,
|
||||
onChanged: (a) => toggleTypingIndicators(),
|
||||
),
|
||||
|
|
@ -106,7 +106,7 @@ class _PrivacyViewState extends State<PrivacyView> {
|
|||
title: Text(context.lang.settingsScreenLock),
|
||||
subtitle: Text(context.lang.settingsScreenLockSubtitle),
|
||||
onTap: toggleAuthRequirementOnStartup,
|
||||
trailing: Switch(
|
||||
trailing: Switch.adaptive(
|
||||
value: userService.currentUser.screenLockEnabled,
|
||||
onChanged: (a) => toggleAuthRequirementOnStartup(),
|
||||
),
|
||||
|
|
|
|||
22
pubspec.lock
22
pubspec.lock
|
|
@ -448,6 +448,13 @@ packages:
|
|||
url: "https://github.com/otsmr/emoji_picker_flutter.git"
|
||||
source: git
|
||||
version: "4.4.0"
|
||||
exif:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "dependencies/exif"
|
||||
relative: true
|
||||
source: path
|
||||
version: "3.3.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1506,6 +1513,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
photo_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: photo_manager
|
||||
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1792,6 +1807,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sprintf:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "dependencies/sprintf"
|
||||
relative: true
|
||||
source: path
|
||||
version: "7.0.0"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ dependencies:
|
|||
flutter_sharing_intent: ^2.0.4
|
||||
screen_protector: ^1.5.1
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
exif: ^3.3.0
|
||||
|
||||
# With high download. (But should be checked nonetheless.)
|
||||
app_links: ^7.0.0 # 1.6 mio
|
||||
|
|
@ -107,6 +108,7 @@ dependencies:
|
|||
flutter_image_compress: ^2.4.0
|
||||
flutter_volume_controller: ^1.3.4
|
||||
gal: ^2.3.1
|
||||
photo_manager: ^3.9.0
|
||||
google_mlkit_barcode_scanning: ^0.14.1
|
||||
google_mlkit_face_detection: ^0.13.1
|
||||
pro_video_editor: ^1.6.1
|
||||
|
|
@ -172,6 +174,10 @@ dependency_overrides:
|
|||
git:
|
||||
url: https://github.com/yenchieh/flutter_android_volume_keydown.git
|
||||
ref: fix/lStar-not-found-error
|
||||
exif:
|
||||
path: ./dependencies/exif
|
||||
sprintf:
|
||||
path: ./dependencies/sprintf
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.15
|
||||
|
|
|
|||
Loading…
Reference in a new issue