first poc for e2ee backup

This commit is contained in:
otsmr 2026-07-19 23:05:50 +02:00
parent 96f8bc39d0
commit 498f5d7b72
20 changed files with 1277 additions and 586 deletions

View file

@ -2,6 +2,7 @@
## 0.4.0 ## 0.4.0
- New: Encrypted Cloud Backup of Memories
- New: Passwordless Backup - New: Passwordless Backup
- Fix: Performance issues - Fix: Performance issues

View file

@ -36,15 +36,15 @@ If you decide to give twonly a try, please keep in mind that it is still in its
- Open Source and can be downloaded directly from GitHub - Open Source and can be downloaded directly from GitHub
- No email or phone number required to register - No email or phone number required to register
- The backend is hosted exclusively in Europe - The backend is hosted exclusively in Europe
- [Passwordless Backup](https://twonly.eu/en/blog/2026-passwordless-backup.html) without a phone number
- [User Discovery](https://twonly.eu/en/blog/2026-mutual-friends.html) without a phone number
- E2EE cloud backup of memories
## Roadmap ## Roadmap
### Currently ### Currently
- Focus on user-friendliness so that people enjoy using the app
- Passwordless recovery without a phone number
- Implementation of features so that Snapchat can actually be replaced - Implementation of features so that Snapchat can actually be replaced
- E2EE cloud backup of memories
- Importing memories from Snapchat - Importing memories from Snapchat
### Next on the bucket list ### Next on the bucket list

View file

@ -1,8 +1,11 @@
import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
part 'mediafiles.dao.g.dart'; part 'mediafiles.dao.g.dart';
@ -217,10 +220,30 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
final rows = await select(mediaFiles).get(); final rows = await select(mediaFiles).get();
final stats = <MediaType, int>{}; final stats = <MediaType, int>{};
final Set<String> existingPaths;
if (rows.isNotEmpty) {
final dummyMs = MediaFileService(rows.first);
final storedDir = dummyMs.storedPath.parent;
if (storedDir.existsSync()) {
existingPaths = storedDir
.listSync()
.whereType<File>()
.map((f) => f.path)
.toSet();
} else {
existingPaths = <String>{};
}
} else {
existingPaths = <String>{};
}
for (final row in rows) { for (final row in rows) {
final type = row.type; final type = row.type;
final size = row.sizeInBytes ?? 0; final ms = MediaFileService(row);
stats[type] = (stats[type] ?? 0) + size; if (existingPaths.contains(ms.storedPath.path)) {
final size = row.sizeInBytes ?? 0;
stats[type] = (stats[type] ?? 0) + size;
}
} }
return stats; return stats;

View file

@ -1442,6 +1442,60 @@ abstract class AppLocalizations {
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'** /// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
String deleteMemoriesBody(num count); String deleteMemoriesBody(num count);
/// No description provided for @deleteMemoriesLocalOnly.
///
/// In en, this message translates to:
/// **'Local Only'**
String get deleteMemoriesLocalOnly;
/// No description provided for @deleteMemoriesCompletely.
///
/// In en, this message translates to:
/// **'Completely'**
String get deleteMemoriesCompletely;
/// No description provided for @memoriesSelectedCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1 {1 Element} other {{count} Elements}}'**
String memoriesSelectedCount(num count);
/// No description provided for @memoriesMenuSelectAll.
///
/// In en, this message translates to:
/// **'Select all'**
String get memoriesMenuSelectAll;
/// No description provided for @memoriesMenuDeselectAll.
///
/// In en, this message translates to:
/// **'Deselect all'**
String get memoriesMenuDeselectAll;
/// No description provided for @memoriesMenuExport.
///
/// In en, this message translates to:
/// **'Export to gallery'**
String get memoriesMenuExport;
/// No description provided for @memoriesMenuFavorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get memoriesMenuFavorite;
/// No description provided for @memoriesMenuDelete.
///
/// In en, this message translates to:
/// **'Delete completely'**
String get memoriesMenuDelete;
/// No description provided for @memoriesMenuDeleteLocal.
///
/// In en, this message translates to:
/// **'Delete locally'**
String get memoriesMenuDeleteLocal;
/// No description provided for @settingsBackup. /// No description provided for @settingsBackup.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -4057,6 +4111,66 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Select Contacts'** /// **'Select Contacts'**
String get missingRecoveryContactsCardAction; String get missingRecoveryContactsCardAction;
/// No description provided for @memoriesBackupLimitReached.
///
/// In en, this message translates to:
/// **'Cloud backup limit reached! Please upgrade your plan or free up space.'**
String get memoriesBackupLimitReached;
/// No description provided for @settingsStorageLocal.
///
/// In en, this message translates to:
/// **'Local storage'**
String get settingsStorageLocal;
/// No description provided for @settingsStorageNoCloudBackupTitle.
///
/// In en, this message translates to:
/// **'No Cloud Backup'**
String get settingsStorageNoCloudBackupTitle;
/// No description provided for @settingsStorageNoCloudBackupCard.
///
/// In en, this message translates to:
/// **'Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.'**
String get settingsStorageNoCloudBackupCard;
/// No description provided for @galleryActionSave.
///
/// In en, this message translates to:
/// **'Save'**
String get galleryActionSave;
/// No description provided for @galleryActionExport.
///
/// In en, this message translates to:
/// **'Export'**
String get galleryActionExport;
/// No description provided for @galleryActionFavorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get galleryActionFavorite;
/// No description provided for @galleryActionUnfavorite.
///
/// In en, this message translates to:
/// **'Unfavorite'**
String get galleryActionUnfavorite;
/// No description provided for @galleryActionDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get galleryActionDelete;
/// No description provided for @galleryActionShare.
///
/// In en, this message translates to:
/// **'Share'**
String get galleryActionShare;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -664,7 +664,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer'; String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer';
@override @override
String get familyFeature3 => '50GB Memories Speicher'; String get familyFeature3 => '50GB Memories Speicher';
@override @override
String get familyFeature4 => '✓ Flammen wiederherstellen'; String get familyFeature4 => '✓ Flammen wiederherstellen';
@ -758,6 +758,41 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String get deleteMemoriesLocalOnly => 'Nur lokal';
@override
String get deleteMemoriesCompletely => 'Komplett';
@override
String memoriesSelectedCount(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Elemente',
one: '1 Element',
);
return '$_temp0';
}
@override
String get memoriesMenuSelectAll => 'Alles auswählen';
@override
String get memoriesMenuDeselectAll => 'Alle abwählen';
@override
String get memoriesMenuExport => 'In Gallery exportieren';
@override
String get memoriesMenuFavorite => 'Favorisieren';
@override
String get memoriesMenuDelete => 'Löschen';
@override
String get memoriesMenuDeleteLocal => 'Lokal löschen';
@override @override
String get settingsBackup => 'Backup'; String get settingsBackup => 'Backup';
@ -2348,4 +2383,36 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get missingRecoveryContactsCardAction => 'Kontakte auswählen'; String get missingRecoveryContactsCardAction => 'Kontakte auswählen';
@override
String get memoriesBackupLimitReached =>
'Cloud-Backup-Limit erreicht! Bitte aktualisiere dein Abonnement oder gib Speicherplatz frei.';
@override
String get settingsStorageLocal => 'Lokaler Speicherplatz';
@override
String get settingsStorageNoCloudBackupTitle => 'Kein Cloud-Backup';
@override
String get settingsStorageNoCloudBackupCard =>
'Sichere deine Erinnerungen in der Cloud, um lokalen Speicherplatz freizugeben und deine Bilder nicht zu verlieren. Alles Ende-zu-Ende verschlüsselt.';
@override
String get galleryActionSave => 'Sichern';
@override
String get galleryActionExport => 'Exportieren';
@override
String get galleryActionFavorite => 'Favorit';
@override
String get galleryActionUnfavorite => 'Favorit';
@override
String get galleryActionDelete => 'Löschen';
@override
String get galleryActionShare => 'Teilen';
} }

View file

@ -753,6 +753,41 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String get deleteMemoriesLocalOnly => 'Local Only';
@override
String get deleteMemoriesCompletely => 'Completely';
@override
String memoriesSelectedCount(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Elements',
one: '1 Element',
);
return '$_temp0';
}
@override
String get memoriesMenuSelectAll => 'Select all';
@override
String get memoriesMenuDeselectAll => 'Deselect all';
@override
String get memoriesMenuExport => 'Export to gallery';
@override
String get memoriesMenuFavorite => 'Favorite';
@override
String get memoriesMenuDelete => 'Delete completely';
@override
String get memoriesMenuDeleteLocal => 'Delete locally';
@override @override
String get settingsBackup => 'Backup'; String get settingsBackup => 'Backup';
@ -2327,4 +2362,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get missingRecoveryContactsCardAction => 'Select Contacts'; String get missingRecoveryContactsCardAction => 'Select Contacts';
@override
String get memoriesBackupLimitReached =>
'Cloud backup limit reached! Please upgrade your plan or free up space.';
@override
String get settingsStorageLocal => 'Local storage';
@override
String get settingsStorageNoCloudBackupTitle => 'No Cloud Backup';
@override
String get settingsStorageNoCloudBackupCard =>
'Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.';
@override
String get galleryActionSave => 'Save';
@override
String get galleryActionExport => 'Export';
@override
String get galleryActionFavorite => 'Favorite';
@override
String get galleryActionUnfavorite => 'Unfavorite';
@override
String get galleryActionDelete => 'Delete';
@override
String get galleryActionShare => 'Share';
} }

@ -1 +1 @@
Subproject commit 499ad4a7b703c1dff414f531c528894ad0c91799 Subproject commit 9d05713a1f69e7e4de742c414df7df6c4c7925a7

View file

@ -6,6 +6,7 @@ import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:mutex/mutex.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -58,6 +59,8 @@ class ProgressMultipartRequest extends http.MultipartRequest {
} }
class MemoriesCloudService { class MemoriesCloudService {
static final Map<String, Mutex> _fileMutexes = {};
Timer? _timer; Timer? _timer;
bool _isProcessing = false; bool _isProcessing = false;
@ -152,24 +155,38 @@ class MemoriesCloudService {
} }
} }
static Future<bool> downloadThumbnail(MediaFileService media) async { static Future<bool> downloadFromCloud(
final urls = await apiService.getMemoriesUrl(media.mediaFile.mediaId, true); MediaFileService media, {
if (urls == null || !urls.hasFullDownloadUrl()) return false; required bool isThumbnail,
}) async {
final mediaId = media.mediaFile.mediaId;
final mutex = _fileMutexes.putIfAbsent(mediaId, Mutex.new);
try { return mutex.protect(() async {
final response = await http.get(Uri.parse(urls.fullDownloadUrl)); final targetPath = isThumbnail ? media.thumbnailPath : media.storedPath;
if (response.statusCode == 200) { if (targetPath.existsSync() && targetPath.lengthSync() > 0) {
return await _decryptFile(response.bodyBytes, media.thumbnailPath); return true;
} else {
Log.warn(
'Failed to download thumbnai statuscode ${response.statusCode}',
);
} }
} catch (e) {
Log.warn(e); final urls = await apiService.getMemoriesUrl(mediaId, isThumbnail);
} if (urls == null || !urls.hasFullDownloadUrl()) return false;
return false;
try {
final response = await http.get(Uri.parse(urls.fullDownloadUrl));
if (response.statusCode == 200) {
return await _decryptFile(response.bodyBytes, targetPath);
} else {
Log.warn(
'Failed to download ${isThumbnail ? 'thumbnail' : 'full media'} statuscode ${response.statusCode}',
);
}
} catch (e) {
Log.warn(e);
}
return false;
});
} }
Future<bool> _backupMemory( Future<bool> _backupMemory(

View file

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
Future<bool?> showDeleteMemoriesDialog({
required BuildContext context,
required int count,
required bool hasCloudBackup,
}) {
return showDialog<bool>(
context: context,
builder: (context) => DeleteMemoriesDialog(
count: count,
hasCloudBackup: hasCloudBackup,
),
);
}
class DeleteMemoriesDialog extends StatelessWidget {
const DeleteMemoriesDialog({
required this.count,
required this.hasCloudBackup,
super.key,
});
final int count;
final bool hasCloudBackup;
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.deleteImageTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const SizedBox(height: 16),
Text(
context.lang.deleteMemoriesBody(count),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
),
const SizedBox(height: 24),
if (hasCloudBackup) ...[
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.deleteMemoriesLocalOnly),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.deleteMemoriesCompletely),
),
),
],
),
const SizedBox(height: 8),
MyButton(
variant: MyButtonVariant.text,
onPressed: () => Navigator.pop(context),
child: Text(context.lang.galleryCancel),
),
] else ...[
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: () => Navigator.pop(context),
child: Text(context.lang.galleryCancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.delete),
),
),
],
),
],
],
),
),
);
}
}

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/themes/light.dart';
class SelectableThumbnailComp extends StatelessWidget { class SelectableThumbnailComp extends StatelessWidget {
const SelectableThumbnailComp({ const SelectableThumbnailComp({
@ -19,7 +19,7 @@ class SelectableThumbnailComp extends StatelessWidget {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut, curve: Curves.easeInOut,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? context.color.primary : Colors.transparent, color: isSelected ? primaryColor : Colors.transparent,
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12, color: Colors.black12,
@ -49,9 +49,7 @@ class SelectableThumbnailComp extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected ? primaryColor : Colors.black38,
? context.color.primary
: Colors.black38,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: Theme.of(context).brightness == Brightness.dark color: Theme.of(context).brightness == Brightness.dark

View file

@ -12,6 +12,7 @@ enum MyButtonVariant {
secondaryDense, secondaryDense,
secondaryMiddle, secondaryMiddle,
error, error,
errorMiddle,
} }
class MyButton extends StatefulWidget { class MyButton extends StatefulWidget {
@ -33,7 +34,6 @@ class MyButton extends StatefulWidget {
} }
class _MyButtonState extends State<MyButton> { class _MyButtonState extends State<MyButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEnabled = widget.onPressed != null || widget.onLongPress != null; final isEnabled = widget.onPressed != null || widget.onLongPress != null;
@ -189,6 +189,25 @@ class _MyButtonState extends State<MyButton> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
); );
case MyButtonVariant.errorMiddle:
buttonStyle = FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(
horizontal: 24,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
} }
final childButton = widget.variant == MyButtonVariant.text final childButton = widget.variant == MyButtonVariant.text

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -91,13 +92,23 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
if (!hasThumbnail) { if (!hasThumbnail) {
if (hasStored) { if (hasStored) {
media.createThumbnail(); unawaited(
media.createThumbnail().then((_) {
if (mounted) {
_resolveImage();
}
}),
);
} else { } else {
MemoriesCloudService.downloadThumbnail(media).then((success) { unawaited(
if (mounted && success) { MemoriesCloudService.downloadFromCloud(media, isThumbnail: true).then(
_resolveImage(); (success) {
} if (mounted && success) {
}); _resolveImage();
}
},
),
);
} }
} }
@ -117,6 +128,8 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId != if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
widget.galleryItem.mediaService.mediaFile.mediaId) { widget.galleryItem.mediaService.mediaFile.mediaId) {
_imageStream?.removeListener(_listener); _imageStream?.removeListener(_listener);
_imageStream = null;
_imageProvider = null;
_imageInfo = null; _imageInfo = null;
_retries = 0; _retries = 0;
_selectedImageFile = null; _selectedImageFile = null;
@ -235,32 +248,45 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
], ],
), ),
), ),
if (media.mediaFile.cloudState == CloudState.pending) Builder(
const Positioned( builder: (context) {
top: 6, final hasStored =
right: 6, media.storedPath.existsSync() &&
child: Icon( media.storedPath.lengthSync() > 0;
Icons.cloud_upload_outlined, final IconData iconData;
color: Colors.white70, final Color color;
size: 16,
shadows: [ switch (media.mediaFile.cloudState) {
Shadow(color: Colors.black54, blurRadius: 4), case CloudState.none:
], iconData = Icons.cloud_off_outlined;
), color = Colors.white54;
) case CloudState.pending:
else if (media.mediaFile.cloudState == CloudState.uploaded) iconData = Icons.cloud_upload_outlined;
const Positioned( color = Colors.white70;
top: 6, case CloudState.uploaded:
right: 6, if (hasStored) {
child: Icon( iconData = Icons.cloud_done_outlined;
Icons.cloud_done_outlined, color = Colors.white;
color: Colors.white, } else {
size: 16, iconData = Icons.cloud_outlined;
shadows: [ color = Colors.white;
Shadow(color: Colors.black54, blurRadius: 4), }
], }
),
), return Positioned(
bottom: 6,
right: 6,
child: Icon(
iconData,
color: color,
size: 16,
shadows: const [
Shadow(color: Colors.black54, blurRadius: 4),
],
),
);
},
),
], ],
), ),
), ),

View file

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
class MemoriesSelectionMenuComp extends StatelessWidget {
const MemoriesSelectionMenuComp({
required this.areAllSelected,
required this.onSelectAll,
required this.onExport,
required this.onFavorite,
required this.onDeleteCompletely,
required this.onDeleteLocally,
super.key,
});
final bool areAllSelected;
final VoidCallback onSelectAll;
final VoidCallback onExport;
final VoidCallback onFavorite;
final VoidCallback onDeleteCompletely;
final VoidCallback onDeleteLocally;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
onSelected: (val) {
switch (val) {
case 'selectAll':
onSelectAll();
case 'export':
onExport();
case 'favorite':
onFavorite();
case 'delete':
onDeleteCompletely();
case 'deleteLocal':
onDeleteLocally();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'selectAll',
child: Row(
children: [
Icon(
areAllSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
const SizedBox(width: 12),
Text(
areAllSelected
? context.lang.memoriesMenuDeselectAll
: context.lang.memoriesMenuSelectAll,
),
],
),
),
PopupMenuItem(
value: 'export',
child: Row(
children: [
const Icon(
Icons.file_download_outlined,
size: 20,
),
const SizedBox(width: 12),
Text(context.lang.memoriesMenuExport),
],
),
),
PopupMenuItem(
value: 'favorite',
child: Row(
children: [
const Icon(
Icons.favorite_border,
size: 20,
),
const SizedBox(width: 12),
Text(context.lang.memoriesMenuFavorite),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Icons.delete_forever_outlined,
size: 20,
color: Colors.redAccent,
),
const SizedBox(width: 12),
Text(
context.lang.memoriesMenuDelete,
style: const TextStyle(color: Colors.redAccent),
),
],
),
),
PopupMenuItem(
value: 'deleteLocal',
child: Row(
children: [
const Icon(
Icons.phonelink_erase_outlined,
size: 20,
),
const SizedBox(width: 12),
Text(context.lang.memoriesMenuDeleteLocal),
],
),
),
],
);
}
}

View file

@ -1,130 +0,0 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
class MemoriesSelectionToolbarComp extends StatelessWidget {
const MemoriesSelectionToolbarComp({
required this.selectedCount,
required this.areAllSelected,
required this.areAllFav,
required this.onSelectAll,
required this.onExport,
required this.onFavorite,
required this.onDelete,
required this.onClear,
super.key,
});
final int selectedCount;
final bool areAllSelected;
final bool areAllFav;
final VoidCallback onSelectAll;
final VoidCallback onExport;
final VoidCallback onFavorite;
final VoidCallback onDelete;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 24,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: context.color.surface.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 16,
offset: Offset(0, 4),
),
],
border: Border.all(
color: context.color.primary.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Text(
'$selectedCount',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
IconButton(
icon: Icon(
areAllSelected ? Icons.deselect : Icons.select_all,
size: 22,
),
onPressed: onSelectAll,
tooltip: areAllSelected
? context.lang.galleryDeselectAll
: context.lang.gallerySelectAll,
style: IconButton.styleFrom(
backgroundColor: context.color.primary.withValues(
alpha: 0.1,
),
foregroundColor: context.color.primary,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.file_download_outlined, size: 22),
onPressed: onExport,
tooltip: context.lang.galleryExport,
style: IconButton.styleFrom(
backgroundColor: context.color.primary.withValues(
alpha: 0.1,
),
foregroundColor: context.color.primary,
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
areAllFav ? Icons.favorite : Icons.favorite_border,
size: 22,
color: areAllFav ? Colors.redAccent : null,
),
onPressed: onFavorite,
tooltip: areAllFav
? context.lang.galleryUnfavorite
: context.lang.galleryFavorite,
style: IconButton.styleFrom(
backgroundColor: context.color.primary.withValues(
alpha: 0.1,
),
foregroundColor: context.color.primary,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.delete_outline, size: 22),
onPressed: onDelete,
tooltip: context.lang.galleryDelete,
style: IconButton.styleFrom(
backgroundColor: Colors.redAccent.withValues(
alpha: 0.1,
),
foregroundColor: Colors.redAccent,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: onClear,
tooltip: context.lang.galleryCancel,
),
],
),
),
);
}
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class SynchronizedViewerActionsToolbarComp extends StatelessWidget { class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
const SynchronizedViewerActionsToolbarComp({ const SynchronizedViewerActionsToolbarComp({
@ -27,94 +26,125 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Positioned( return Padding(
bottom: MediaQuery.paddingOf(context).bottom + 24, padding: const EdgeInsets.all(12),
left: 0, child: Container(
right: 0, decoration: BoxDecoration(
child: Row( color: Colors.black.withValues(alpha: 0.50),
mainAxisAlignment: MainAxisAlignment.center, borderRadius: BorderRadius.circular(20),
children: [ ),
if (showStoreButton) ...[ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
IconButton( child: Row(
icon: isImageSaving mainAxisAlignment: MainAxisAlignment.spaceAround,
? const SizedBox( children: [
width: 16, if (showStoreButton)
height: 16, _ToolbarAction(
child: CircularProgressIndicator.adaptive( icon: isImageSaving
strokeWidth: 2, ? const SizedBox(
valueColor: AlwaysStoppedAnimation(Colors.white), width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: const FaIcon(
FontAwesomeIcons.floppyDisk,
color: Colors.white,
size: 18,
), ),
) label: context.lang.galleryActionSave,
: const FaIcon( onTap: isImageSaving ? null : onStore,
FontAwesomeIcons.floppyDisk,
color: Colors.white,
size: 20,
),
onPressed: isImageSaving ? null : onStore,
tooltip: 'Store media',
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
), ),
_ToolbarAction(
icon: const FaIcon(
FontAwesomeIcons.download,
color: Colors.white,
size: 19,
),
label: context.lang.galleryActionExport,
onTap: onExport,
),
_ToolbarAction(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.redAccent : Colors.white,
size: 22,
),
label: isFavorite
? context.lang.galleryActionUnfavorite
: context.lang.galleryActionFavorite,
onTap: onToggleFavorite,
),
_ToolbarAction(
icon: const Icon(
Icons.delete,
color: Colors.white,
size: 22,
),
label: context.lang.galleryActionDelete,
onTap: onDelete,
),
_ToolbarAction(
icon: const FaIcon(
FontAwesomeIcons.shareNodes,
color: Colors.white,
size: 19,
),
label: context.lang.galleryActionShare,
onTap: onShare,
), ),
const SizedBox(width: 16),
], ],
IconButton( ),
icon: const FaIcon( ),
FontAwesomeIcons.fileArrowDown, );
color: Colors.white, }
size: 21, }
),
onPressed: onExport, class _ToolbarAction extends StatelessWidget {
tooltip: context.lang.galleryExport, const _ToolbarAction({
style: IconButton.styleFrom( required this.icon,
backgroundColor: Colors.black54, required this.label,
padding: const EdgeInsets.all(12), required this.onTap,
), });
),
const SizedBox(width: 16), final Widget icon;
IconButton( final String label;
icon: Icon( final VoidCallback? onTap;
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.redAccent : Colors.white, @override
size: 24, Widget build(BuildContext context) {
), return Expanded(
onPressed: onToggleFavorite, child: Opacity(
tooltip: 'Favorite', opacity: onTap == null ? 0.4 : 1.0,
style: IconButton.styleFrom( child: InkWell(
backgroundColor: Colors.black54, onTap: onTap,
padding: const EdgeInsets.all(12), borderRadius: BorderRadius.circular(12),
), child: Padding(
), padding: const EdgeInsets.symmetric(vertical: 8),
const SizedBox(width: 16), child: Column(
IconButton( mainAxisSize: MainAxisSize.min,
icon: const Icon( children: [
Icons.delete_outline, SizedBox(
color: Colors.white, height: 24,
size: 24, child: Center(child: icon),
), ),
onPressed: onDelete, const SizedBox(height: 6),
tooltip: context.lang.galleryDelete, Text(
style: IconButton.styleFrom( label,
backgroundColor: Colors.black54, maxLines: 1,
padding: const EdgeInsets.all(12), overflow: TextOverflow.ellipsis,
), style: TextStyle(
), color: Colors.white.withValues(alpha: 0.9),
const SizedBox(width: 16), fontSize: 10,
IconButton( fontWeight: FontWeight.w600,
icon: const FaIcon( letterSpacing: 0.1,
FontAwesomeIcons.solidPaperPlane, ),
color: primaryColor, ),
size: 22, ],
), ),
onPressed: onShare, ),
tooltip: context.lang.shareImagedEditorSendImage, ),
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
padding: const EdgeInsets.all(12),
),
),
],
), ),
); );
} }

View file

@ -0,0 +1,162 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
class SynchronizedViewerItemComp extends StatefulWidget {
const SynchronizedViewerItemComp({
required this.item,
required this.currentlyViewedMediaIdNotifier,
required this.onZoomChanged,
super.key,
});
final MemoryItem item;
final ValueNotifier<String> currentlyViewedMediaIdNotifier;
final ValueChanged<bool> onZoomChanged;
@override
State<SynchronizedViewerItemComp> createState() =>
_SynchronizedViewerItemCompState();
}
class _SynchronizedViewerItemCompState
extends State<SynchronizedViewerItemComp> {
bool _isDownloading = false;
@override
void initState() {
super.initState();
_checkAndDownload();
}
@override
void didUpdateWidget(covariant SynchronizedViewerItemComp oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.item.mediaService.mediaFile.mediaId !=
widget.item.mediaService.mediaFile.mediaId) {
_checkAndDownload();
}
}
void _checkAndDownload() {
final media = widget.item.mediaService;
final hasStored =
media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
if (!hasStored &&
media.mediaFile.cloudState == CloudState.uploaded &&
!_isDownloading) {
_isDownloading = true;
unawaited(
MemoriesCloudService.downloadFromCloud(
media,
isThumbnail: false,
).then((success) async {
if (success && mounted) {
final fullPath = media.storedPath;
if (media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif) {
if (fullPath.existsSync()) {
try {
await precacheImage(FileImage(fullPath), context);
} catch (e) {
// Ignore precache errors
}
}
}
}
if (mounted) {
setState(() {
_isDownloading = false;
});
}
}),
);
}
}
@override
Widget build(BuildContext context) {
final item = widget.item;
final itemMediaId = item.mediaService.mediaFile.mediaId;
var filePath = item.mediaService.storedPath;
final hasStored = filePath.existsSync() && filePath.lengthSync() > 0;
if (!hasStored) {
filePath = item.mediaService.tempPath;
final hasTemp = filePath.existsSync() && filePath.lengthSync() > 0;
if (!hasTemp) {
filePath = item.mediaService.thumbnailPath;
}
}
final isVideo = item.mediaService.mediaFile.type == MediaType.video;
final hasVideoFile =
isVideo &&
(item.mediaService.storedPath.existsSync() &&
item.mediaService.storedPath.lengthSync() > 0 ||
item.mediaService.tempPath.existsSync() &&
item.mediaService.tempPath.lengthSync() > 0);
return Center(
child: ValueListenableBuilder<String>(
valueListenable: widget.currentlyViewedMediaIdNotifier,
builder: (context, activeMediaId, childWidget) {
final isActiveTarget = activeMediaId == itemMediaId;
if (isActiveTarget) {
return Hero(
tag: itemMediaId,
transitionOnUserGestures: true,
child: childWidget!,
);
}
return childWidget!;
},
child: !filePath.existsSync() || filePath.lengthSync() == 0
? item.mediaService.mediaFile.blurhash != null
? BlurHash(
hash: item.mediaService.mediaFile.blurhash!,
optimizationMode: BlurHashOptimizationMode.approximation,
)
: const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
)
: hasVideoFile
? VideoPlayerFileHelper(videoPath: filePath)
: PhotoView(
key: ValueKey(filePath.path),
imageProvider: FileImage(filePath),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
scaleStateChangedCallback: (state) {
final zoomed = state != PhotoViewScaleState.initial;
widget.onZoomChanged(zoomed);
},
),
),
);
}
}

View file

@ -7,11 +7,12 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/memories/memories.service.dart'; import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart'; import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart'; import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart'; import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
import 'package:twonly/src/visual/views/memories/components/selection_toolbar.comp.dart'; import 'package:twonly/src/visual/views/memories/components/selection_menu.comp.dart';
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart'; import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
class MemoriesView extends StatefulWidget { class MemoriesView extends StatefulWidget {
@ -270,57 +271,41 @@ class MemoriesViewState extends State<MemoriesView>
} }
} }
Future<void> _batchDelete() async { Future<void> _batchDelete({bool? forceDeleteCompletely}) async {
final count = _selectedMediaIds.length; final count = _selectedMediaIds.length;
final items = _service.currentState.galleryItems; final items = _service.currentState.galleryItems;
final selectedList = _selectedMediaIds.toList(); final selectedList = _selectedMediaIds.toList();
var hasCloudBackup = false; var deleteCompletely = forceDeleteCompletely;
for (final id in selectedList) { if (deleteCompletely == null) {
final item = items var hasCloudBackup = false;
.where((e) => e.mediaService.mediaFile.mediaId == id) for (final id in selectedList) {
.firstOrNull; final item = items
if (item != null && .where((e) => e.mediaService.mediaFile.mediaId == id)
item.mediaService.mediaFile.cloudState != CloudState.none) { .firstOrNull;
hasCloudBackup = true; if (item != null &&
break; item.mediaService.mediaFile.cloudState != CloudState.none) {
hasCloudBackup = true;
break;
}
} }
}
bool? deleteCompletely; deleteCompletely = await showDeleteMemoriesDialog(
if (hasCloudBackup) {
deleteCompletely = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( count: count,
title: Text(context.lang.deleteImageTitle), hasCloudBackup: hasCloudBackup,
content: Text(context.lang.deleteMemoriesBody(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.lang.galleryCancel),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Local Only'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Completely'),
),
],
),
); );
} else { } else if (deleteCompletely) {
final confirmed = await showAlertDialog( final confirmed = await showAlertDialog(
context, context,
context.lang.deleteImageTitle, context.lang.deleteImageTitle,
context.lang.deleteMemoriesBody(count), context.lang.deleteMemoriesBody(count),
); );
if (confirmed) deleteCompletely = true; if (!confirmed) return;
} }
if (deleteCompletely == null) return; if (deleteCompletely == null) return;
final isCompletely = deleteCompletely;
await _showProgressDialog( await _showProgressDialog(
'Deleting memories...', 'Deleting memories...',
@ -331,12 +316,14 @@ class MemoriesViewState extends State<MemoriesView>
.where((e) => e.mediaService.mediaFile.mediaId == mediaId) .where((e) => e.mediaService.mediaFile.mediaId == mediaId)
.firstOrNull; .firstOrNull;
if (item != null) { if (item != null) {
if (deleteCompletely!) { if (isCompletely) {
item.mediaService.fullMediaRemoval(); item.mediaService.fullMediaRemoval();
await apiService.deleteMemory(mediaId); await apiService.deleteMemory(mediaId);
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
} else { } else {
item.mediaService.storedPath.deleteSync(); if (item.mediaService.storedPath.existsSync()) {
item.mediaService.storedPath.deleteSync();
}
} }
} }
setProgress((i + 1) / selectedList.length); setProgress((i + 1) / selectedList.length);
@ -515,9 +502,9 @@ class MemoriesViewState extends State<MemoriesView>
vertical: 8, vertical: 8,
horizontal: 16, horizontal: 16,
), ),
child: const Text( child: Text(
'Cloud backup limit reached! Please upgrade your plan or free up space.', context.lang.memoriesBackupLimitReached,
style: TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -565,65 +552,128 @@ class MemoriesViewState extends State<MemoriesView>
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
title: const Text( leading: _selectionMode
'Memories', ? IconButton(
style: TextStyle(fontWeight: FontWeight.bold), icon: const Icon(Icons.arrow_back),
), onPressed: () =>
setState(_selectedMediaIds.clear),
)
: null,
title: _selectionMode
? Builder(
builder: (context) => Text(
context.lang.memoriesSelectedCount(
_selectedMediaIds.length,
),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
)
: const Text(
'Memories',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
pinned: _selectionMode,
floating: true, floating: true,
snap: true, snap: true,
elevation: 0, elevation: 0,
backgroundColor: context.color.surface, backgroundColor: context.color.surface,
actions: [ actions: _selectionMode
if (state.isLoading) ? [
Padding( IconButton(
padding: const EdgeInsets.symmetric( icon: const Icon(
horizontal: 16, Icons.delete_outline,
), ),
child: Center( onPressed: _batchDelete,
child: Tooltip( ),
message: context.lang Builder(
.migrationOfMemories( builder: (context) {
state.filesToMigrate, final visibleCount =
), _filterFavoritesOnly
child: SizedBox( ? state.galleryItems
width: 20, .where(
height: 20, (e) => e
child: CircularProgressIndicator( .mediaService
value: state.migrationProgress, .mediaFile
strokeWidth: 2.5, .isFavorite,
valueColor: )
AlwaysStoppedAnimation( .length
context.color.primary, : state.galleryItems.length;
final areAllSelected =
visibleCount > 0 &&
_selectedMediaIds.length >=
visibleCount;
return MemoriesSelectionMenuComp(
areAllSelected: areAllSelected,
onSelectAll: _selectAll,
onExport: _batchExport,
onFavorite: _batchFavorite,
onDeleteCompletely: () =>
_batchDelete(
forceDeleteCompletely: true,
), ),
backgroundColor: context onDeleteLocally: () =>
.color _batchDelete(
.primary forceDeleteCompletely:
.withValues(alpha: 0.2), false,
),
);
},
),
]
: [
if (state.isLoading)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Center(
child: Tooltip(
message: context.lang
.migrationOfMemories(
state.filesToMigrate,
),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
value:
state.migrationProgress,
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation(
context.color.primary,
),
backgroundColor: context
.color
.primary
.withValues(alpha: 0.2),
),
),
),
), ),
), ),
IconButton(
icon: Icon(
_filterFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _filterFavoritesOnly
? Colors.redAccent
: null,
),
onPressed: () {
setState(() {
_filterFavoritesOnly =
!_filterFavoritesOnly;
});
},
), ),
), ],
),
IconButton(
icon: Icon(
_filterFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _filterFavoritesOnly
? Colors.redAccent
: null,
),
onPressed: () {
setState(() {
_filterFavoritesOnly =
!_filterFavoritesOnly;
});
},
tooltip: _filterFavoritesOnly
? 'Show all'
: 'Show favorites only',
),
],
), ),
MemoriesFlashbackBannerComp( MemoriesFlashbackBannerComp(
lastYears: lastYears, lastYears: lastYears,
@ -703,47 +753,6 @@ class MemoriesViewState extends State<MemoriesView>
); );
}, },
), ),
if (_selectionMode)
Builder(
builder: (context) {
final items = _service.currentState.galleryItems;
var visibleCount = 0;
var favCount = 0;
for (final item in items) {
final isFav = item.mediaService.mediaFile.isFavorite;
if (!_filterFavoritesOnly || isFav) {
visibleCount++;
}
if (_selectedMediaIds.contains(
item.mediaService.mediaFile.mediaId,
)) {
if (isFav) {
favCount++;
}
}
}
final areAllSelected =
visibleCount > 0 &&
_selectedMediaIds.length >= visibleCount;
final areAllFav =
_selectedMediaIds.isNotEmpty &&
favCount == _selectedMediaIds.length;
return MemoriesSelectionToolbarComp(
selectedCount: _selectedMediaIds.length,
areAllSelected: areAllSelected,
areAllFav: areAllFav,
onSelectAll: _selectAll,
onExport: _batchExport,
onFavorite: _batchFavorite,
onDelete: _batchDelete,
onClear: () => setState(_selectedMediaIds.clear),
);
},
),
], ],
), ),
); );

View file

@ -2,8 +2,6 @@ import 'dart:math';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
@ -11,11 +9,11 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart'; import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart';
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_item.comp.dart';
class SynchronizedImageViewerScreen extends StatefulWidget { class SynchronizedImageViewerScreen extends StatefulWidget {
const SynchronizedImageViewerScreen({ const SynchronizedImageViewerScreen({
@ -68,7 +66,7 @@ class _SynchronizedImageViewerScreenState
if (item.mediaService.mediaFile.isFavorite) { if (item.mediaService.mediaFile.isFavorite) {
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId); _favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
} }
if (item.mediaService.storedPath.existsSync()) { if (item.mediaService.mediaFile.stored) {
_storedMediaIds.add(item.mediaService.mediaFile.mediaId); _storedMediaIds.add(item.mediaService.mediaFile.mediaId);
} }
} }
@ -157,36 +155,46 @@ class _SynchronizedImageViewerScreenState
} }
Future<void> _deleteFile() async { Future<void> _deleteFile() async {
final confirmed = await showAlertDialog( final item = widget.galleryItems[_currentIndex];
context, final mediaId = item.mediaService.mediaFile.mediaId;
context.lang.deleteImageTitle, final hasCloudBackup =
context.lang.deleteImageBody, item.mediaService.mediaFile.cloudState != CloudState.none;
final deleteCompletely = await showDeleteMemoriesDialog(
context: context,
count: 1,
hasCloudBackup: hasCloudBackup,
); );
if (!confirmed) return; if (deleteCompletely == null) return;
widget.galleryItems[_currentIndex].mediaService.fullMediaRemoval(); if (deleteCompletely) {
await twonlyDB.mediaFilesDao.deleteMediaFile( item.mediaService.fullMediaRemoval();
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId, await apiService.deleteMemory(mediaId);
); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
widget.galleryItems.removeAt(_currentIndex); widget.galleryItems.removeAt(_currentIndex);
if (widget.galleryItems.isEmpty) { if (widget.galleryItems.isEmpty) {
if (mounted) Navigator.pop(context, true); if (mounted) Navigator.pop(context, true);
return; return;
}
if (_currentIndex >= widget.galleryItems.length) {
_currentIndex = widget.galleryItems.length - 1;
}
final newId =
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
_currentlyViewedMediaIdNotifier.value = newId;
widget.activeMediaIdNotifier.value = newId;
setState(() {});
} else {
if (item.mediaService.storedPath.existsSync()) {
item.mediaService.storedPath.deleteSync();
}
} }
if (_currentIndex >= widget.galleryItems.length) {
_currentIndex = widget.galleryItems.length - 1;
}
final newId =
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
_currentlyViewedMediaIdNotifier.value = newId;
widget.activeMediaIdNotifier.value = newId;
setState(() {});
} }
Future<void> _exportFile() async { Future<void> _exportFile() async {
@ -308,99 +316,50 @@ class _SynchronizedImageViewerScreenState
widget.activeMediaIdNotifier.value = newMediaId; widget.activeMediaIdNotifier.value = newMediaId;
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = widget.galleryItems[index]; return SynchronizedViewerItemComp(
final itemMediaId = item.mediaService.mediaFile.mediaId; item: widget.galleryItems[index],
currentlyViewedMediaIdNotifier:
var filePath = item.mediaService.storedPath; _currentlyViewedMediaIdNotifier,
if (!filePath.existsSync()) { onZoomChanged: (zoomed) {
filePath = item.mediaService.tempPath; if (_isZoomed != zoomed) {
if (!filePath.existsSync()) { setState(() {
filePath = item.mediaService.thumbnailPath; _isZoomed = zoomed;
} });
} }
},
final isVideo =
item.mediaService.mediaFile.type == MediaType.video;
return Center(
child: ValueListenableBuilder<String>(
valueListenable: _currentlyViewedMediaIdNotifier,
builder: (context, activeMediaId, childWidget) {
// Dynamically resolve Hero tags to prevent layout tree duplicate assertions
final isActiveTarget = activeMediaId == itemMediaId;
if (isActiveTarget) {
return Hero(
tag: itemMediaId,
transitionOnUserGestures: true,
child: childWidget!,
);
}
return childWidget!;
},
child: !filePath.existsSync()
? item.mediaService.mediaFile.blurhash != null
? BlurHash(
hash: item
.mediaService
.mediaFile
.blurhash!,
optimizationMode:
BlurHashOptimizationMode
.approximation,
)
: const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
)
: isVideo
? VideoPlayerFileHelper(videoPath: filePath)
: PhotoView(
imageProvider: FileImage(filePath),
initialScale:
PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale:
PhotoViewComputedScale.covered * 4.1,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.broken_image_outlined,
color: Colors.white38,
size: 64,
),
);
},
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
),
),
); );
}, },
), ),
SynchronizedViewerActionsToolbarComp( Positioned(
isFavorite: _favoritedMediaIds.contains(currentMediaId), bottom: MediaQuery.paddingOf(context).bottom + 16,
onShare: _shareMediaFile, left: 0,
onExport: _exportFile, right: 0,
onToggleFavorite: () => _toggleFavorite(currentMediaId), child: AnimatedOpacity(
onDelete: _deleteFile, opacity: _isZoomed ? 0.0 : 1.0,
showStoreButton: !_storedMediaIds.contains(currentMediaId), duration: const Duration(milliseconds: 200),
onStore: _storeMediaFile, curve: Curves.easeInOut,
isImageSaving: _isSaving, child: IgnorePointer(
ignoring: _isZoomed,
child: Center(
child: SynchronizedViewerActionsToolbarComp(
isFavorite: _favoritedMediaIds.contains(
currentMediaId,
),
onShare: _shareMediaFile,
onExport: _exportFile,
onToggleFavorite: () =>
_toggleFavorite(currentMediaId),
onDelete: _deleteFile,
showStoreButton: !_storedMediaIds.contains(
currentMediaId,
),
onStore: _storeMediaFile,
isImageSaving: _isSaving,
),
),
),
),
), ),
], ],
), ),

View file

@ -5,10 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/backup.model.dart'; import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/status.passwordless_recovery.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/status.passwordless_recovery.comp.dart';
@ -24,7 +21,6 @@ class BackupView extends StatefulWidget {
class _BackupViewState extends State<BackupView> { class _BackupViewState extends State<BackupView> {
bool _isLoading = false; bool _isLoading = false;
CurrentBackupStatus? _backupStatus; CurrentBackupStatus? _backupStatus;
server.Response_MemoriesUsage? _memoriesUsage;
StreamSubscription<void>? _backupUpdateSub; StreamSubscription<void>? _backupUpdateSub;
@override @override
@ -45,11 +41,9 @@ class _BackupViewState extends State<BackupView> {
Future<void> _loadBackupStatus() async { Future<void> _loadBackupStatus() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final status = await BackupService.getData(); final status = await BackupService.getData();
final memoriesUsage = await apiService.getMemoriesUsage();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_backupStatus = status; _backupStatus = status;
_memoriesUsage = memoriesUsage;
_isLoading = false; _isLoading = false;
}); });
} }
@ -188,58 +182,6 @@ class _BackupViewState extends State<BackupView> {
), ),
]), ]),
), ),
const SizedBox(height: 24),
const Center(
child: Text(
'Memories Backup',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 8),
Table(
defaultVerticalAlignment:
TableCellVerticalAlignment.middle,
children: _buildTableRows([
(
'Usage',
_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-',
),
(
'Files Backed Up',
_memoriesUsage != null
? '${_memoriesUsage!.count}'
: '-',
),
]),
),
StreamBuilder<MemoriesBackupProgress>(
initialData: memoriesCloudService.currentProgress,
stream: memoriesCloudService.progressStream,
builder: (context, snapshot) {
final progress = snapshot.data;
if (progress == null || progress.totalPending == 0) {
return const SizedBox.shrink();
}
final percent = (progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress / progress.totalPending);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: percent.clamp(0.0, 1.0),
),
],
);
},
),
], ],
), ),
@ -250,7 +192,7 @@ class _BackupViewState extends State<BackupView> {
variant: MyButtonVariant.primaryMiddle, variant: MyButtonVariant.primaryMiddle,
onPressed: () => onPressed: () =>
context.navPush(const PasswordLessRecoverySetup()), context.navPush(const PasswordLessRecoverySetup()),
child: const Text('Setup Passwordless Recovery'), child: Text(context.lang.passwordlessRecoveryEnableBtn),
), ),
), ),
], ],

View file

@ -1,6 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
class ManageStorageView extends StatefulWidget { class ManageStorageView extends StatefulWidget {
@ -12,6 +20,7 @@ class ManageStorageView extends StatefulWidget {
class _ManageStorageViewState extends State<ManageStorageView> { class _ManageStorageViewState extends State<ManageStorageView> {
Map<MediaType, int> _stats = {}; Map<MediaType, int> _stats = {};
server.Response_MemoriesUsage? _memoriesUsage;
@override @override
void initState() { void initState() {
@ -21,15 +30,20 @@ class _ManageStorageViewState extends State<ManageStorageView> {
Future<void> _loadStats() async { Future<void> _loadStats() async {
final stats = await twonlyDB.mediaFilesDao.getStorageStats(); final stats = await twonlyDB.mediaFilesDao.getStorageStats();
final memoriesUsage = await apiService.getMemoriesUsage();
if (mounted) { if (mounted) {
setState(() { setState(() {
_stats = stats; _stats = stats;
_memoriesUsage = memoriesUsage;
}); });
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentPlan = context.watch<PurchasesProvider>().plan;
final isFreePlan = currentPlan == SubscriptionPlan.Free;
final totalBytes = _stats.entries final totalBytes = _stats.entries
.where((e) => e.key != MediaType.audio) .where((e) => e.key != MediaType.audio)
.fold<int>(0, (a, b) => a + b.value); .fold<int>(0, (a, b) => a + b.value);
@ -44,8 +58,148 @@ class _ManageStorageViewState extends State<ManageStorageView> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (isFreePlan) ...[
Card(
elevation: 0,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: InkWell(
onTap: () => context.push(Routes.settingsSubscription),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.cloud_off_outlined,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.settingsStorageNoCloudBackupTitle,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
context.lang.settingsStorageNoCloudBackupCard,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
),
const SizedBox(height: 24),
] else ...[
Text(
'Memories Backup',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Container(
height: 24,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
if (_memoriesUsage == null ||
_memoriesUsage!.maxBytes == 0) {
return const SizedBox.shrink();
}
final maxWidth = constraints.maxWidth;
final current = _memoriesUsage!.currentBytes.toDouble();
final max = _memoriesUsage!.maxBytes.toDouble();
final usageWidth =
((current / max).clamp(0.0, 1.0)) * maxWidth;
return Row(
children: [
if (usageWidth > 0)
Container(
width: usageWidth,
color: Colors.blue,
),
],
);
},
),
),
),
StreamBuilder<MemoriesBackupProgress>(
initialData: memoriesCloudService.currentProgress,
stream: memoriesCloudService.progressStream,
builder: (context, snapshot) {
final progress = snapshot.data;
if (progress == null || progress.totalPending == 0) {
return const SizedBox.shrink();
}
final percent =
(progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress / progress.totalPending);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: percent.clamp(0.0, 1.0),
),
],
);
},
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
],
Text( Text(
context.lang.settingsStorageUsed, isFreePlan
? context.lang.settingsStorageUsed
: context.lang.settingsStorageLocal,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),