From 8a4632c4c4e0b40267b059b1e85baea893365250 Mon Sep 17 00:00:00 2001 From: otsmr Date: Thu, 13 Nov 2025 23:57:01 +0100 Subject: [PATCH] fix #303 and bump version --- CHANGELOG.md | 3 + ios/Podfile.lock | 45 ++++ lib/src/localization/app_de.arb | 4 +- lib/src/localization/app_en.arb | 4 +- .../generated/app_localizations.dart | 12 ++ .../generated/app_localizations_de.dart | 6 + .../generated/app_localizations_en.dart | 6 + .../mediafiles/mediafile.service.dart | 6 +- .../save_to_gallery.dart | 5 +- .../views/settings/data_and_storage.view.dart | 32 +++ .../data_and_storage/export_media.view.dart | 175 ++++++++++++++++ .../data_and_storage/import_media.view.dart | 192 ++++++++++++++++++ pubspec.lock | 10 +- pubspec.yaml | 4 +- 14 files changed, 493 insertions(+), 11 deletions(-) create mode 100644 lib/src/views/settings/data_and_storage/export_media.view.dart create mode 100644 lib/src/views/settings/data_and_storage/import_media.view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0d00d..d1d2b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## 0.0.69 +- Option to export and import memories +- iOS support for ultra-wide-angle camera - Support Android Monochrome Icon +- Multiple layout issues fixed - Multiple bug fixes ## 0.0.67 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 59e9f0b..fc2dc0e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -11,6 +11,37 @@ PODS: - Flutter - device_info_plus (0.0.1): - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif - emoji_picker_flutter (0.0.1): - Flutter - ffmpeg_kit_flutter_new (1.0.0): @@ -18,6 +49,9 @@ PODS: - Flutter - ffmpeg_kit_flutter_new/full-gpl (1.0.0): - Flutter + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Firebase (12.4.0): - Firebase/Core (= 12.4.0) - Firebase/Core (12.4.0): @@ -249,6 +283,7 @@ PODS: - sqlite3/rtree - sqlite3/session - SwiftProtobuf (1.33.1) + - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): @@ -264,6 +299,7 @@ DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - ffmpeg_kit_flutter_new (from `.symlinks/plugins/ffmpeg_kit_flutter_new/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Firebase - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) @@ -298,6 +334,8 @@ DEPENDENCIES: SPEC REPOS: trunk: + - DKImagePickerController + - DKPhotoGallery - Firebase - FirebaseAnalytics - FirebaseCore @@ -318,6 +356,7 @@ SPEC REPOS: - Sentry - sqlite3 - SwiftProtobuf + - SwiftyGif EXTERNAL SOURCES: audio_waveforms: @@ -336,6 +375,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/emoji_picker_flutter/ios" ffmpeg_kit_flutter_new: :path: ".symlinks/plugins/ffmpeg_kit_flutter_new/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" firebase_messaging: @@ -394,8 +435,11 @@ SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc ffmpeg_kit_flutter_new: 12426a19f10ac81186c67c6ebc4717f8f4364b7f + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594 firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde @@ -439,6 +483,7 @@ SPEC CHECKSUMS: sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 SwiftProtobuf: 533a18409c3ca3a6156b2b1e46afd0f69e751aba + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index a554839..3c344f2 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -827,5 +827,7 @@ "avatarSaveChangesStore": "Speichern", "avatarSaveChangesDiscard": "Verwerfen", "inProcess": "Wird verarbeitet", - "draftMessage": "Entwurf" + "draftMessage": "Entwurf", + "exportMemories": "Memories exportieren (Beta)", + "importMemories": "Memories importieren (Beta)" } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 66b16d2..df2ccc6 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -605,5 +605,7 @@ "avatarSaveChangesStore": "Save", "avatarSaveChangesDiscard": "Discard", "inProcess": "In process", - "draftMessage": "Draft" + "draftMessage": "Draft", + "exportMemories": "Export memories (Beta)", + "importMemories": "Import memories (Beta)" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 1aceb64..54d0805 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2737,6 +2737,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Draft'** String get draftMessage; + + /// No description provided for @exportMemories. + /// + /// In en, this message translates to: + /// **'Export memories (Beta)'** + String get exportMemories; + + /// No description provided for @importMemories. + /// + /// In en, this message translates to: + /// **'Import memories (Beta)'** + String get importMemories; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index f7544fc..2f14fda 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1510,4 +1510,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get draftMessage => 'Entwurf'; + + @override + String get exportMemories => 'Memories exportieren (Beta)'; + + @override + String get importMemories => 'Memories importieren (Beta)'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 31189c0..5763b12 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1500,4 +1500,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get draftMessage => 'Draft'; + + @override + String get exportMemories => 'Export memories (Beta)'; + + @override + String get importMemories => 'Import memories (Beta)'; } diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 417d50e..6ef6b62 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -33,7 +33,7 @@ class MediaFileService { } static Future purgeTempFolder() async { - final tempDirectory = MediaFileService._buildDirectoryPath( + final tempDirectory = MediaFileService.buildDirectoryPath( 'tmp', await getApplicationSupportDirectory(), ); @@ -239,7 +239,7 @@ class MediaFileService { await updateFromDB(); } - static Directory _buildDirectoryPath( + static Directory buildDirectoryPath( String directory, Directory applicationSupportDirectory, ) { @@ -275,7 +275,7 @@ class MediaFileService { } } final mediaBaseDir = - _buildDirectoryPath(directory, applicationSupportDirectory); + buildDirectoryPath(directory, applicationSupportDirectory); return File( join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'), ); diff --git a/lib/src/views/camera/camera_preview_components/save_to_gallery.dart b/lib/src/views/camera/camera_preview_components/save_to_gallery.dart index 1251fbb..f1984a5 100644 --- a/lib/src/views/camera/camera_preview_components/save_to_gallery.dart +++ b/lib/src/views/camera/camera_preview_components/save_to_gallery.dart @@ -46,10 +46,7 @@ class SaveToGalleryButtonState extends State { _imageSaving = true; }); - if (widget.mediaService.mediaFile.type == MediaType.image || - widget.mediaService.mediaFile.type == MediaType.gif) { - await widget.storeImageAsOriginal(); - } + await widget.storeImageAsOriginal(); String? res; diff --git a/lib/src/views/settings/data_and_storage.view.dart b/lib/src/views/settings/data_and_storage.view.dart index 2ddbf59..3a39e19 100644 --- a/lib/src/views/settings/data_and_storage.view.dart +++ b/lib/src/views/settings/data_and_storage.view.dart @@ -6,6 +6,8 @@ import 'package:twonly/globals.dart'; import 'package:twonly/src/services/api/mediafiles/download.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/settings/data_and_storage/export_media.view.dart'; +import 'package:twonly/src/views/settings/data_and_storage/import_media.view.dart'; class DataAndStorageView extends StatefulWidget { const DataAndStorageView({super.key}); @@ -62,6 +64,36 @@ class _DataAndStorageViewState extends State { onChanged: (a) => toggleStoreInGallery(), ), ), + ListTile( + title: Text( + context.lang.exportMemories, + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + return const ExportMediaView(); + }, + ), + ); + }, + ), + ListTile( + title: Text( + context.lang.importMemories, + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + return const ImportMediaView(); + }, + ), + ); + }, + ), const Divider(), ListTile( title: Text( diff --git a/lib/src/views/settings/data_and_storage/export_media.view.dart b/lib/src/views/settings/data_and_storage/export_media.view.dart new file mode 100644 index 0000000..2b89b05 --- /dev/null +++ b/lib/src/views/settings/data_and_storage/export_media.view.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; + +class ExportMediaView extends StatefulWidget { + const ExportMediaView({super.key}); + + @override + State createState() => _ExportMediaViewState(); +} + +class _ExportMediaViewState extends State { + double _progress = 0; + String? _status; + File? _zipFile; + bool _isZipping = false; + bool _zipSaved = false; + + Future _mediaFolder() async { + final dir = MediaFileService.buildDirectoryPath( + 'stored', + await getApplicationSupportDirectory(), + ); + if (!dir.existsSync()) await dir.create(recursive: true); + return dir; + } + + Future _createZipFromMediaFolder() async { + setState(() { + _isZipping = true; + _progress = 0.0; + _status = 'Preparing...'; + _zipFile = null; + }); + + try { + final folder = await _mediaFolder(); + final allFiles = + folder.listSync(recursive: true).whereType().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'; + }); + + // ZipFileEncoder doesn't give per-file progress; update after adding. + 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 _saveZip() async { + if (_zipFile == null) return; + try { + 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; + _status = 'ZIP stored: ${p.basename(_zipFile!.path)}'; + setState(() {}); + } catch (e) { + 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) ? _saveZip : null, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/views/settings/data_and_storage/import_media.view.dart b/lib/src/views/settings/data_and_storage/import_media.view.dart new file mode 100644 index 0000000..69fd0e1 --- /dev/null +++ b/lib/src/views/settings/data_and_storage/import_media.view.dart @@ -0,0 +1,192 @@ +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/globals.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 createState() => _ImportMediaViewState(); +} + +class _ImportMediaViewState extends State { + double _progress = 0; + String? _status; + File? _zipFile; + bool _isProcessing = false; + + Future _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 _extractZipToMediaFolder(File zipFile) async { + setState(() { + _status = 'Reading archive...'; + _progress = 0; + }); + + try { + // Read zip bytes and decode + final bytes = await zipFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + // 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.insertMedia( + MediaFilesCompanion( + type: Value(type), + createdAt: Value(file.lastModDateTime), + stored: const Value(true), + ), + ); + final mediaService = await MediaFileService.fromMedia(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, + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 76d323c..95c6a9a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,7 +34,7 @@ packages: source: hosted version: "8.4.1" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" @@ -442,6 +442,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967 + url: "https://pub.dev" + source: hosted + version: "10.3.6" file_selector_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8ce28a4..f377f5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,12 +3,13 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.0.68+68 +version: 0.0.69+69 environment: sdk: ^3.6.0 dependencies: + archive: ^4.0.7 audio_waveforms: ^1.3.0 avatar_maker: ^0.4.0 background_downloader: ^9.2.2 @@ -23,6 +24,7 @@ dependencies: drift_flutter: ^0.2.4 emoji_picker_flutter: ^4.3.0 ffmpeg_kit_flutter_new: ^4.1.0 + file_picker: ^10.3.6 firebase_core: ^4.2.0 firebase_messaging: ^16.0.3 fixnum: ^1.1.1