diff --git a/CHANGELOG.md b/CHANGELOG.md index e6772f04..5e14c284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.4.0 +- New: Encrypted Cloud Backup of Memories - New: Passwordless Backup - Fix: Performance issues diff --git a/README.md b/README.md index c4ba2a2a..024c9eea 100644 --- a/README.md +++ b/README.md @@ -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 - No email or phone number required to register - 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 ### 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 - - E2EE cloud backup of memories - Importing memories from Snapchat ### Next on the bucket list diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 040faf50..2e5d18dd 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -1,8 +1,11 @@ +import 'dart:io'; + import 'package:drift/drift.dart'; import 'package:hashlib/random.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.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'; part 'mediafiles.dao.g.dart'; @@ -217,10 +220,30 @@ class MediaFilesDao extends DatabaseAccessor final rows = await select(mediaFiles).get(); final stats = {}; + final Set existingPaths; + if (rows.isNotEmpty) { + final dummyMs = MediaFileService(rows.first); + final storedDir = dummyMs.storedPath.parent; + if (storedDir.existsSync()) { + existingPaths = storedDir + .listSync() + .whereType() + .map((f) => f.path) + .toSet(); + } else { + existingPaths = {}; + } + } else { + existingPaths = {}; + } + for (final row in rows) { final type = row.type; - final size = row.sizeInBytes ?? 0; - stats[type] = (stats[type] ?? 0) + size; + final ms = MediaFileService(row); + if (existingPaths.contains(ms.storedPath.path)) { + final size = row.sizeInBytes ?? 0; + stats[type] = (stats[type] ?? 0) + size; + } } return stats; diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index cde12c55..348d0259 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1442,6 +1442,60 @@ abstract class AppLocalizations { /// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'** String deleteMemoriesBody(num count); + /// No description provided for @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. /// /// In en, this message translates to: @@ -4057,6 +4111,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Select Contacts'** 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 diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 31e7cf75..88402311 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -664,7 +664,7 @@ class AppLocalizationsDe extends AppLocalizations { String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer'; @override - String get familyFeature3 => '50GB Memories Speicher'; + String get familyFeature3 => '✓ 50GB Memories Speicher'; @override String get familyFeature4 => '✓ Flammen wiederherstellen'; @@ -758,6 +758,41 @@ class AppLocalizationsDe extends AppLocalizations { 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 String get settingsBackup => 'Backup'; @@ -2348,4 +2383,36 @@ class AppLocalizationsDe extends AppLocalizations { @override 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'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 550684a3..1c4ab096 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -753,6 +753,41 @@ class AppLocalizationsEn extends AppLocalizations { 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 String get settingsBackup => 'Backup'; @@ -2327,4 +2362,36 @@ class AppLocalizationsEn extends AppLocalizations { @override 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'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 499ad4a7..9d05713a 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 499ad4a7b703c1dff414f531c528894ad0c91799 +Subproject commit 9d05713a1f69e7e4de742c414df7df6c4c7925a7 diff --git a/lib/src/services/memories/memories_cloud.service.dart b/lib/src/services/memories/memories_cloud.service.dart index f5ff0789..fc6c3e21 100644 --- a/lib/src/services/memories/memories_cloud.service.dart +++ b/lib/src/services/memories/memories_cloud.service.dart @@ -6,6 +6,7 @@ import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:mutex/mutex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/locator.dart'; @@ -58,6 +59,8 @@ class ProgressMultipartRequest extends http.MultipartRequest { } class MemoriesCloudService { + static final Map _fileMutexes = {}; + Timer? _timer; bool _isProcessing = false; @@ -152,24 +155,38 @@ class MemoriesCloudService { } } - static Future downloadThumbnail(MediaFileService media) async { - final urls = await apiService.getMemoriesUrl(media.mediaFile.mediaId, true); - if (urls == null || !urls.hasFullDownloadUrl()) return false; + static Future downloadFromCloud( + MediaFileService media, { + required bool isThumbnail, + }) async { + final mediaId = media.mediaFile.mediaId; + final mutex = _fileMutexes.putIfAbsent(mediaId, Mutex.new); - try { - final response = await http.get(Uri.parse(urls.fullDownloadUrl)); + return mutex.protect(() async { + final targetPath = isThumbnail ? media.thumbnailPath : media.storedPath; - if (response.statusCode == 200) { - return await _decryptFile(response.bodyBytes, media.thumbnailPath); - } else { - Log.warn( - 'Failed to download thumbnai statuscode ${response.statusCode}', - ); + if (targetPath.existsSync() && targetPath.lengthSync() > 0) { + return true; } - } catch (e) { - Log.warn(e); - } - return false; + + final urls = await apiService.getMemoriesUrl(mediaId, isThumbnail); + if (urls == null || !urls.hasFullDownloadUrl()) 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 _backupMemory( diff --git a/lib/src/visual/components/delete_memories_dialog.comp.dart b/lib/src/visual/components/delete_memories_dialog.comp.dart new file mode 100644 index 00000000..3dd307c3 --- /dev/null +++ b/lib/src/visual/components/delete_memories_dialog.comp.dart @@ -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 showDeleteMemoriesDialog({ + required BuildContext context, + required int count, + required bool hasCloudBackup, +}) { + return showDialog( + 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), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/components/selectable_thumbnail.comp.dart b/lib/src/visual/components/selectable_thumbnail.comp.dart index 572b017d..58c3c212 100644 --- a/lib/src/visual/components/selectable_thumbnail.comp.dart +++ b/lib/src/visual/components/selectable_thumbnail.comp.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/themes/light.dart'; class SelectableThumbnailComp extends StatelessWidget { const SelectableThumbnailComp({ @@ -19,7 +19,7 @@ class SelectableThumbnailComp extends StatelessWidget { duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, decoration: BoxDecoration( - color: isSelected ? context.color.primary : Colors.transparent, + color: isSelected ? primaryColor : Colors.transparent, boxShadow: const [ BoxShadow( color: Colors.black12, @@ -49,9 +49,7 @@ class SelectableThumbnailComp extends StatelessWidget { child: Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( - color: isSelected - ? context.color.primary - : Colors.black38, + color: isSelected ? primaryColor : Colors.black38, shape: BoxShape.circle, border: Border.all( color: Theme.of(context).brightness == Brightness.dark diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index ee7ca730..c3038416 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -12,6 +12,7 @@ enum MyButtonVariant { secondaryDense, secondaryMiddle, error, + errorMiddle, } class MyButton extends StatefulWidget { @@ -33,7 +34,6 @@ class MyButton extends StatefulWidget { } class _MyButtonState extends State { - @override Widget build(BuildContext context) { final isEnabled = widget.onPressed != null || widget.onLongPress != null; @@ -189,6 +189,25 @@ class _MyButtonState extends State { 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 diff --git a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart index dff1a207..2f018520 100644 --- a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart +++ b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -91,13 +92,23 @@ class _MemoriesThumbnailCompState extends State { if (!hasThumbnail) { if (hasStored) { - media.createThumbnail(); + unawaited( + media.createThumbnail().then((_) { + if (mounted) { + _resolveImage(); + } + }), + ); } else { - MemoriesCloudService.downloadThumbnail(media).then((success) { - if (mounted && success) { - _resolveImage(); - } - }); + unawaited( + MemoriesCloudService.downloadFromCloud(media, isThumbnail: true).then( + (success) { + if (mounted && success) { + _resolveImage(); + } + }, + ), + ); } } @@ -117,6 +128,8 @@ class _MemoriesThumbnailCompState extends State { if (oldWidget.galleryItem.mediaService.mediaFile.mediaId != widget.galleryItem.mediaService.mediaFile.mediaId) { _imageStream?.removeListener(_listener); + _imageStream = null; + _imageProvider = null; _imageInfo = null; _retries = 0; _selectedImageFile = null; @@ -235,32 +248,45 @@ class _MemoriesThumbnailCompState extends State { ], ), ), - if (media.mediaFile.cloudState == CloudState.pending) - const Positioned( - top: 6, - right: 6, - child: Icon( - Icons.cloud_upload_outlined, - color: Colors.white70, - size: 16, - shadows: [ - Shadow(color: Colors.black54, blurRadius: 4), - ], - ), - ) - else if (media.mediaFile.cloudState == CloudState.uploaded) - const Positioned( - top: 6, - right: 6, - child: Icon( - Icons.cloud_done_outlined, - color: Colors.white, - size: 16, - shadows: [ - Shadow(color: Colors.black54, blurRadius: 4), - ], - ), - ), + Builder( + builder: (context) { + final hasStored = + media.storedPath.existsSync() && + media.storedPath.lengthSync() > 0; + final IconData iconData; + final Color color; + + switch (media.mediaFile.cloudState) { + case CloudState.none: + iconData = Icons.cloud_off_outlined; + color = Colors.white54; + case CloudState.pending: + iconData = Icons.cloud_upload_outlined; + color = Colors.white70; + case CloudState.uploaded: + if (hasStored) { + iconData = Icons.cloud_done_outlined; + color = Colors.white; + } else { + iconData = Icons.cloud_outlined; + color = Colors.white; + } + } + + return Positioned( + bottom: 6, + right: 6, + child: Icon( + iconData, + color: color, + size: 16, + shadows: const [ + Shadow(color: Colors.black54, blurRadius: 4), + ], + ), + ); + }, + ), ], ), ), diff --git a/lib/src/visual/views/memories/components/selection_menu.comp.dart b/lib/src/visual/views/memories/components/selection_menu.comp.dart new file mode 100644 index 00000000..a198f640 --- /dev/null +++ b/lib/src/visual/views/memories/components/selection_menu.comp.dart @@ -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( + 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), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/memories/components/selection_toolbar.comp.dart b/lib/src/visual/views/memories/components/selection_toolbar.comp.dart deleted file mode 100644 index daf3b5a2..00000000 --- a/lib/src/visual/views/memories/components/selection_toolbar.comp.dart +++ /dev/null @@ -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, - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart b/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart index 1f786fbd..370e093a 100644 --- a/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart +++ b/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/themes/light.dart'; class SynchronizedViewerActionsToolbarComp extends StatelessWidget { const SynchronizedViewerActionsToolbarComp({ @@ -27,94 +26,125 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget { @override Widget build(BuildContext context) { - return Positioned( - bottom: MediaQuery.paddingOf(context).bottom + 24, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (showStoreButton) ...[ - IconButton( - icon: isImageSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + return Padding( + padding: const EdgeInsets.all(12), + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.50), + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (showStoreButton) + _ToolbarAction( + icon: isImageSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const FaIcon( + FontAwesomeIcons.floppyDisk, + color: Colors.white, + size: 18, ), - ) - : const FaIcon( - 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), + label: context.lang.galleryActionSave, + onTap: isImageSaving ? null : onStore, ), + _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, - tooltip: context.lang.galleryExport, - style: IconButton.styleFrom( - backgroundColor: Colors.black54, - padding: const EdgeInsets.all(12), - ), - ), - const SizedBox(width: 16), - IconButton( - icon: Icon( - isFavorite ? Icons.favorite : Icons.favorite_border, - color: isFavorite ? Colors.redAccent : Colors.white, - size: 24, - ), - onPressed: onToggleFavorite, - tooltip: 'Favorite', - style: IconButton.styleFrom( - backgroundColor: Colors.black54, - padding: const EdgeInsets.all(12), - ), - ), - const SizedBox(width: 16), - IconButton( - icon: const Icon( - Icons.delete_outline, - color: Colors.white, - size: 24, - ), - onPressed: onDelete, - tooltip: context.lang.galleryDelete, - style: IconButton.styleFrom( - backgroundColor: Colors.black54, - padding: const EdgeInsets.all(12), - ), - ), - const SizedBox(width: 16), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.solidPaperPlane, - color: primaryColor, - size: 22, - ), - onPressed: onShare, - tooltip: context.lang.shareImagedEditorSendImage, - style: IconButton.styleFrom( - backgroundColor: Colors.black54, - padding: const EdgeInsets.all(12), - ), - ), - ], + ), + ), + ); + } +} + +class _ToolbarAction extends StatelessWidget { + const _ToolbarAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final Widget icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Opacity( + opacity: onTap == null ? 0.4 : 1.0, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 24, + child: Center(child: icon), + ), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ], + ), + ), + ), ), ); } diff --git a/lib/src/visual/views/memories/components/synchronized_viewer_item.comp.dart b/lib/src/visual/views/memories/components/synchronized_viewer_item.comp.dart new file mode 100644 index 00000000..083d3ca4 --- /dev/null +++ b/lib/src/visual/views/memories/components/synchronized_viewer_item.comp.dart @@ -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 currentlyViewedMediaIdNotifier; + final ValueChanged onZoomChanged; + + @override + State createState() => + _SynchronizedViewerItemCompState(); +} + +class _SynchronizedViewerItemCompState + extends State { + 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( + 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); + }, + ), + ), + ); + } +} diff --git a/lib/src/visual/views/memories/memories.view.dart b/lib/src/visual/views/memories/memories.view.dart index b4071fc6..867e5516 100644 --- a/lib/src/visual/views/memories/memories.view.dart +++ b/lib/src/visual/views/memories/memories.view.dart @@ -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/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/draggable_scrollbar.comp.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/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'; class MemoriesView extends StatefulWidget { @@ -270,57 +271,41 @@ class MemoriesViewState extends State } } - Future _batchDelete() async { + Future _batchDelete({bool? forceDeleteCompletely}) async { final count = _selectedMediaIds.length; final items = _service.currentState.galleryItems; final selectedList = _selectedMediaIds.toList(); - var hasCloudBackup = false; - for (final id in selectedList) { - final item = items - .where((e) => e.mediaService.mediaFile.mediaId == id) - .firstOrNull; - if (item != null && - item.mediaService.mediaFile.cloudState != CloudState.none) { - hasCloudBackup = true; - break; + var deleteCompletely = forceDeleteCompletely; + if (deleteCompletely == null) { + var hasCloudBackup = false; + for (final id in selectedList) { + final item = items + .where((e) => e.mediaService.mediaFile.mediaId == id) + .firstOrNull; + if (item != null && + item.mediaService.mediaFile.cloudState != CloudState.none) { + hasCloudBackup = true; + break; + } } - } - bool? deleteCompletely; - if (hasCloudBackup) { - deleteCompletely = await showDialog( + deleteCompletely = await showDeleteMemoriesDialog( context: context, - builder: (context) => AlertDialog( - title: Text(context.lang.deleteImageTitle), - 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'), - ), - ], - ), + count: count, + hasCloudBackup: hasCloudBackup, ); - } else { + } else if (deleteCompletely) { final confirmed = await showAlertDialog( context, context.lang.deleteImageTitle, context.lang.deleteMemoriesBody(count), ); - if (confirmed) deleteCompletely = true; + if (!confirmed) return; } if (deleteCompletely == null) return; + final isCompletely = deleteCompletely; await _showProgressDialog( 'Deleting memories...', @@ -331,12 +316,14 @@ class MemoriesViewState extends State .where((e) => e.mediaService.mediaFile.mediaId == mediaId) .firstOrNull; if (item != null) { - if (deleteCompletely!) { + if (isCompletely) { item.mediaService.fullMediaRemoval(); await apiService.deleteMemory(mediaId); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); } else { - item.mediaService.storedPath.deleteSync(); + if (item.mediaService.storedPath.existsSync()) { + item.mediaService.storedPath.deleteSync(); + } } } setProgress((i + 1) / selectedList.length); @@ -515,9 +502,9 @@ class MemoriesViewState extends State vertical: 8, horizontal: 16, ), - child: const Text( - 'Cloud backup limit reached! Please upgrade your plan or free up space.', - style: TextStyle( + child: Text( + context.lang.memoriesBackupLimitReached, + style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), @@ -565,65 +552,128 @@ class MemoriesViewState extends State physics: const BouncingScrollPhysics(), slivers: [ SliverAppBar( - title: const Text( - 'Memories', - style: TextStyle(fontWeight: FontWeight.bold), - ), + leading: _selectionMode + ? IconButton( + 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, snap: true, elevation: 0, backgroundColor: context.color.surface, - actions: [ - 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, + actions: _selectionMode + ? [ + IconButton( + icon: const Icon( + Icons.delete_outline, + ), + onPressed: _batchDelete, + ), + Builder( + builder: (context) { + final visibleCount = + _filterFavoritesOnly + ? state.galleryItems + .where( + (e) => e + .mediaService + .mediaFile + .isFavorite, + ) + .length + : 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 - .color - .primary - .withValues(alpha: 0.2), + onDeleteLocally: () => + _batchDelete( + forceDeleteCompletely: + 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( lastYears: lastYears, @@ -703,47 +753,6 @@ class MemoriesViewState extends State ); }, ), - - 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), - ); - }, - ), ], ), ); diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 97eeca52..c5ecbdbe 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -2,8 +2,6 @@ import 'dart:math'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.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/src/database/tables/mediafiles.table.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/utils/log.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/helpers/video_player_file.helper.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_item.comp.dart'; class SynchronizedImageViewerScreen extends StatefulWidget { const SynchronizedImageViewerScreen({ @@ -68,7 +66,7 @@ class _SynchronizedImageViewerScreenState if (item.mediaService.mediaFile.isFavorite) { _favoritedMediaIds.add(item.mediaService.mediaFile.mediaId); } - if (item.mediaService.storedPath.existsSync()) { + if (item.mediaService.mediaFile.stored) { _storedMediaIds.add(item.mediaService.mediaFile.mediaId); } } @@ -157,36 +155,46 @@ class _SynchronizedImageViewerScreenState } Future _deleteFile() async { - final confirmed = await showAlertDialog( - context, - context.lang.deleteImageTitle, - context.lang.deleteImageBody, + final item = widget.galleryItems[_currentIndex]; + final mediaId = item.mediaService.mediaFile.mediaId; + final hasCloudBackup = + 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(); - await twonlyDB.mediaFilesDao.deleteMediaFile( - widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId, - ); + if (deleteCompletely) { + item.mediaService.fullMediaRemoval(); + await apiService.deleteMemory(mediaId); + await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); - widget.galleryItems.removeAt(_currentIndex); + widget.galleryItems.removeAt(_currentIndex); - if (widget.galleryItems.isEmpty) { - if (mounted) Navigator.pop(context, true); - return; + if (widget.galleryItems.isEmpty) { + if (mounted) Navigator.pop(context, true); + 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 _exportFile() async { @@ -308,99 +316,50 @@ class _SynchronizedImageViewerScreenState widget.activeMediaIdNotifier.value = newMediaId; }, itemBuilder: (context, index) { - final item = widget.galleryItems[index]; - final itemMediaId = item.mediaService.mediaFile.mediaId; - - var filePath = item.mediaService.storedPath; - if (!filePath.existsSync()) { - filePath = item.mediaService.tempPath; - if (!filePath.existsSync()) { - filePath = item.mediaService.thumbnailPath; - } - } - - final isVideo = - item.mediaService.mediaFile.type == MediaType.video; - - return Center( - child: ValueListenableBuilder( - 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; - }); - } - }, - ), - ), + return SynchronizedViewerItemComp( + item: widget.galleryItems[index], + currentlyViewedMediaIdNotifier: + _currentlyViewedMediaIdNotifier, + onZoomChanged: (zoomed) { + if (_isZoomed != zoomed) { + setState(() { + _isZoomed = zoomed; + }); + } + }, ); }, ), - SynchronizedViewerActionsToolbarComp( - isFavorite: _favoritedMediaIds.contains(currentMediaId), - onShare: _shareMediaFile, - onExport: _exportFile, - onToggleFavorite: () => _toggleFavorite(currentMediaId), - onDelete: _deleteFile, - showStoreButton: !_storedMediaIds.contains(currentMediaId), - onStore: _storeMediaFile, - isImageSaving: _isSaving, + Positioned( + bottom: MediaQuery.paddingOf(context).bottom + 16, + left: 0, + right: 0, + child: AnimatedOpacity( + opacity: _isZoomed ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + 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, + ), + ), + ), + ), ), ], ), diff --git a/lib/src/visual/views/settings/backup/backup_settings.view.dart b/lib/src/visual/views/settings/backup/backup_settings.view.dart index a19c0652..b2c62163 100644 --- a/lib/src/visual/views/settings/backup/backup_settings.view.dart +++ b/lib/src/visual/views/settings/backup/backup_settings.view.dart @@ -5,10 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.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/memories/memories_cloud.service.dart'; import 'package:twonly/src/utils/misc.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'; @@ -24,7 +21,6 @@ class BackupView extends StatefulWidget { class _BackupViewState extends State { bool _isLoading = false; CurrentBackupStatus? _backupStatus; - server.Response_MemoriesUsage? _memoriesUsage; StreamSubscription? _backupUpdateSub; @override @@ -45,11 +41,9 @@ class _BackupViewState extends State { Future _loadBackupStatus() async { setState(() => _isLoading = true); final status = await BackupService.getData(); - final memoriesUsage = await apiService.getMemoriesUsage(); if (!mounted) return; setState(() { _backupStatus = status; - _memoriesUsage = memoriesUsage; _isLoading = false; }); } @@ -188,58 +182,6 @@ class _BackupViewState extends State { ), ]), ), - 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( - 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 { variant: MyButtonVariant.primaryMiddle, onPressed: () => context.navPush(const PasswordLessRecoverySetup()), - child: const Text('Setup Passwordless Recovery'), + child: Text(context.lang.passwordlessRecoveryEnableBtn), ), ), ], diff --git a/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart b/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart index 73308b3b..6731d122 100644 --- a/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart +++ b/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart @@ -1,6 +1,14 @@ 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/src/constants/routes.keys.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'; class ManageStorageView extends StatefulWidget { @@ -12,6 +20,7 @@ class ManageStorageView extends StatefulWidget { class _ManageStorageViewState extends State { Map _stats = {}; + server.Response_MemoriesUsage? _memoriesUsage; @override void initState() { @@ -21,15 +30,20 @@ class _ManageStorageViewState extends State { Future _loadStats() async { final stats = await twonlyDB.mediaFilesDao.getStorageStats(); + final memoriesUsage = await apiService.getMemoriesUsage(); if (mounted) { setState(() { _stats = stats; + _memoriesUsage = memoriesUsage; }); } } @override Widget build(BuildContext context) { + final currentPlan = context.watch().plan; + final isFreePlan = currentPlan == SubscriptionPlan.Free; + final totalBytes = _stats.entries .where((e) => e.key != MediaType.audio) .fold(0, (a, b) => a + b.value); @@ -44,8 +58,148 @@ class _ManageStorageViewState extends State { body: ListView( padding: const EdgeInsets.all(16), 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( + 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( - context.lang.settingsStorageUsed, + isFreePlan + ? context.lang.settingsStorageUsed + : context.lang.settingsStorageLocal, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8),