fix #303 and bump version

This commit is contained in:
otsmr 2025-11-13 23:57:01 +01:00
parent fa85dbd6e2
commit 8a4632c4c4
14 changed files with 493 additions and 11 deletions

View file

@ -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

View file

@ -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

View file

@ -827,5 +827,7 @@
"avatarSaveChangesStore": "Speichern",
"avatarSaveChangesDiscard": "Verwerfen",
"inProcess": "Wird verarbeitet",
"draftMessage": "Entwurf"
"draftMessage": "Entwurf",
"exportMemories": "Memories exportieren (Beta)",
"importMemories": "Memories importieren (Beta)"
}

View file

@ -605,5 +605,7 @@
"avatarSaveChangesStore": "Save",
"avatarSaveChangesDiscard": "Discard",
"inProcess": "In process",
"draftMessage": "Draft"
"draftMessage": "Draft",
"exportMemories": "Export memories (Beta)",
"importMemories": "Import memories (Beta)"
}

View file

@ -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

View file

@ -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)';
}

View file

@ -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)';
}

View file

@ -33,7 +33,7 @@ class MediaFileService {
}
static Future<void> 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'),
);

View file

@ -46,10 +46,7 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
_imageSaving = true;
});
if (widget.mediaService.mediaFile.type == MediaType.image ||
widget.mediaService.mediaFile.type == MediaType.gif) {
await widget.storeImageAsOriginal();
}
String? res;

View file

@ -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<DataAndStorageView> {
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(

View file

@ -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<ExportMediaView> createState() => _ExportMediaViewState();
}
class _ExportMediaViewState extends State<ExportMediaView> {
double _progress = 0;
String? _status;
File? _zipFile;
bool _isZipping = false;
bool _zipSaved = false;
Future<Directory> _mediaFolder() async {
final dir = MediaFileService.buildDirectoryPath(
'stored',
await getApplicationSupportDirectory(),
);
if (!dir.existsSync()) await dir.create(recursive: true);
return dir;
}
Future<void> _createZipFromMediaFolder() async {
setState(() {
_isZipping = true;
_progress = 0.0;
_status = 'Preparing...';
_zipFile = null;
});
try {
final folder = await _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';
});
// 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<void> _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,
),
],
),
),
);
}
}

View file

@ -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<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 {
// 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,
),
],
),
),
);
}
}

View file

@ -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:

View file

@ -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