diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 1630b743..df6aaf34 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4189,6 +4189,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'News'** String get settingsHelpNews; + + /// No description provided for @settingsStorageContents. + /// + /// In en, this message translates to: + /// **'Storage contents'** + String get settingsStorageContents; + + /// No description provided for @settingsStorageSortStorage. + /// + /// In en, this message translates to: + /// **'Occupied storage'** + String get settingsStorageSortStorage; + + /// No description provided for @settingsStorageSortNewest. + /// + /// In en, this message translates to: + /// **'Newest'** + String get settingsStorageSortNewest; + + /// No description provided for @settingsStorageSortOldest. + /// + /// In en, this message translates to: + /// **'Oldest'** + String get settingsStorageSortOldest; + + /// No description provided for @settingsStorageNoContents. + /// + /// In en, this message translates to: + /// **'No storage contents found.'** + String get settingsStorageNoContents; + + /// No description provided for @settingsStorageCloudOnly. + /// + /// In en, this message translates to: + /// **'Cloud only'** + String get settingsStorageCloudOnly; + + /// No description provided for @settingsStorageLocalAndCloud. + /// + /// In en, this message translates to: + /// **'Local and cloud'** + String get settingsStorageLocalAndCloud; + + /// No description provided for @settingsStorageLocalOnly. + /// + /// In en, this message translates to: + /// **'Local only'** + String get settingsStorageLocalOnly; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index cee06bc0..f8a094f0 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2424,4 +2424,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsHelpNews => 'Neuigkeiten'; + + @override + String get settingsStorageContents => 'Speicherinhalte'; + + @override + String get settingsStorageSortStorage => 'Belegter Speicher'; + + @override + String get settingsStorageSortNewest => 'Neueste'; + + @override + String get settingsStorageSortOldest => 'Älteste'; + + @override + String get settingsStorageNoContents => 'Keine Speicherinhalte gefunden.'; + + @override + String get settingsStorageCloudOnly => 'Nur Cloud'; + + @override + String get settingsStorageLocalAndCloud => 'Lokal & Cloud'; + + @override + String get settingsStorageLocalOnly => 'Nur lokal'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index fa36c5c3..1b98deda 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2403,4 +2403,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsHelpNews => 'News'; + + @override + String get settingsStorageContents => 'Storage contents'; + + @override + String get settingsStorageSortStorage => 'Occupied storage'; + + @override + String get settingsStorageSortNewest => 'Newest'; + + @override + String get settingsStorageSortOldest => 'Oldest'; + + @override + String get settingsStorageNoContents => 'No storage contents found.'; + + @override + String get settingsStorageCloudOnly => 'Cloud only'; + + @override + String get settingsStorageLocalAndCloud => 'Local and cloud'; + + @override + String get settingsStorageLocalOnly => 'Local only'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 1727cd6d..bcfb2b02 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 1727cd6d4f2c56f976883497c182b1456033b614 +Subproject commit bcfb2b02c59d1e3afe66b310296cb0f03eb75a72 diff --git a/lib/src/visual/views/memories/components/selection_menu.comp.dart b/lib/src/visual/views/memories/components/selection_menu.comp.dart index a198f640..4433f90c 100644 --- a/lib/src/visual/views/memories/components/selection_menu.comp.dart +++ b/lib/src/visual/views/memories/components/selection_menu.comp.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; import 'package:twonly/src/utils/misc.dart'; class MemoriesSelectionMenuComp extends StatelessWidget { @@ -97,19 +98,20 @@ class MemoriesSelectionMenuComp extends StatelessWidget { ], ), ), - PopupMenuItem( - value: 'deleteLocal', - child: Row( - children: [ - const Icon( - Icons.phonelink_erase_outlined, - size: 20, - ), - const SizedBox(width: 12), - Text(context.lang.memoriesMenuDeleteLocal), - ], + if (userService.currentUser.isCloudBackupEnabled) + 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/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 45e3897e..b1a80246 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -71,7 +71,9 @@ class _SynchronizedImageViewerScreenState if (item.mediaService.mediaFile.isFavorite) { _favoritedMediaIds.add(item.mediaService.mediaFile.mediaId); } - if (item.mediaService.mediaFile.stored) { + if (item.mediaService.mediaFile.stored || + (item.mediaService.storedPath.existsSync() && + item.mediaService.storedPath.lengthSync() > 0)) { _storedMediaIds.add(item.mediaService.mediaFile.mediaId); } } 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 9eb13ff8..c3ffc121 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 @@ -13,6 +13,7 @@ import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/user.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/data_and_storage/storage_contents.view.dart'; class ManageStorageView extends StatefulWidget { const ManageStorageView({super.key}); @@ -300,6 +301,14 @@ class _ManageStorageViewState extends State { size: formatBytes(gifBytes), color: Colors.orange, ), + const SizedBox(height: 32), + Align( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: () => context.navPush(const StorageContentsView()), + child: Text(context.lang.settingsStorageContents), + ), + ), ], ), ); diff --git a/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart b/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart new file mode 100644 index 00000000..c8f00f6b --- /dev/null +++ b/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_blurhash/flutter_blurhash.dart'; +import 'package:intl/intl.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; + +enum StorageSortOption { + size, + newest, + oldest, +} + +class StorageContentsView extends StatefulWidget { + const StorageContentsView({super.key}); + + @override + State createState() => _StorageContentsViewState(); +} + +class _StorageContentsViewState extends State { + StorageSortOption _sortOption = StorageSortOption.size; + final Set _selectedMediaIds = {}; + late final Stream> _mediaFilesStream; + + @override + void initState() { + super.initState(); + _mediaFilesStream = twonlyDB.mediaFilesDao.watchAllStoredMediaFiles(); + } + + void _toggleSelection(String mediaId) { + setState(() { + if (_selectedMediaIds.contains(mediaId)) { + _selectedMediaIds.remove(mediaId); + } else { + _selectedMediaIds.add(mediaId); + } + }); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: _mediaFilesStream, + builder: (context, snapshot) { + final allFiles = snapshot.data ?? []; + final storedFiles = allFiles.where((file) { + final ms = MediaFileService(file); + return file.stored || + (ms.storedPath.existsSync() && ms.storedPath.lengthSync() > 0); + }).toList(); + + // Sort the files + switch (_sortOption) { + case StorageSortOption.size: + storedFiles.sort( + (a, b) => + (b.sizeInBytes ?? 0).compareTo(a.sizeInBytes ?? 0), + ); + case StorageSortOption.newest: + storedFiles.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + case StorageSortOption.oldest: + storedFiles.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } + + final isSelecting = _selectedMediaIds.isNotEmpty; + + return Scaffold( + appBar: AppBar( + leading: isSelecting + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(_selectedMediaIds.clear); + }, + ) + : null, + title: isSelecting + ? Text( + context.lang.memoriesSelectedCount(_selectedMediaIds.length), + style: const TextStyle(fontWeight: FontWeight.bold), + ) + : Text(context.lang.settingsStorageContents), + actions: [ + if (isSelecting) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _batchDelete(context, storedFiles), + ), + ], + ), + body: snapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : snapshot.hasError + ? Center(child: Text('Error: ${snapshot.error}')) + : storedFiles.isEmpty + ? Center( + child: Text( + context.lang.settingsStorageNoContents, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.grey), + ), + ) + : Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildChip( + StorageSortOption.size, + context.lang.settingsStorageSortStorage, + ), + const SizedBox(width: 8), + _buildChip( + StorageSortOption.newest, + context.lang.settingsStorageSortNewest, + ), + const SizedBox(width: 8), + _buildChip( + StorageSortOption.oldest, + context.lang.settingsStorageSortOldest, + ), + ], + ), + ), + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: storedFiles.length, + itemBuilder: (context, index) { + final file = storedFiles[index]; + return _buildListItem(context, file); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildChip(StorageSortOption option, String label) { + final isSelected = _sortOption == option; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _sortOption = option; + }); + } + }, + ); + } + + Widget _buildListItem(BuildContext context, MediaFile file) { + final sizeStr = formatBytes(file.sizeInBytes ?? 0); + final dateStr = DateFormat.yMMMd( + Localizations.localeOf(context).toString(), + ).format(file.createdAt); + + final isSelected = _selectedMediaIds.contains(file.mediaId); + final isSelecting = _selectedMediaIds.isNotEmpty; + + final ms = MediaFileService(file); + final hasStored = file.stored || + (ms.storedPath.existsSync() && ms.storedPath.lengthSync() > 0); + + final IconData statusIcon; + final String statusText; + + switch (file.cloudState) { + case CloudState.none: + statusIcon = Icons.cloud_off_outlined; + statusText = context.lang.settingsStorageLocalOnly; + case CloudState.pending: + statusIcon = Icons.cloud_upload_outlined; + statusText = context.lang.settingsStorageLocalOnly; + case CloudState.uploaded: + if (hasStored) { + statusIcon = Icons.cloud_done_outlined; + statusText = context.lang.settingsStorageLocalAndCloud; + } else { + statusIcon = Icons.cloud_outlined; + statusText = context.lang.settingsStorageCloudOnly; + } + } + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + minLeadingWidth: 0, + horizontalTitleGap: 8, + selected: isSelected, + onTap: () => _toggleSelection(file.mediaId), + onLongPress: () => _toggleSelection(file.mediaId), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelecting) ...[ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: isSelected, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (val) => _toggleSelection(file.mediaId), + ), + ), + const SizedBox(width: 8), + ], + _buildThumbnail(context, file), + ], + ), + title: Text('$sizeStr • $dateStr'), + subtitle: Row( + children: [ + Text( + file.type.name.toUpperCase(), + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(width: 8), + Icon(statusIcon, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + statusText, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + trailing: isSelecting + ? null + : IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + onPressed: () => _deleteMemory(context, file), + ), + ); + } + + Widget _buildThumbnail(BuildContext context, MediaFile file) { + final ms = MediaFileService(file); + final isVideo = file.type == MediaType.video; + + final imgFile = ms.thumbnailPath.existsSync() && + ms.thumbnailPath.lengthSync() > 0 + ? ms.thumbnailPath + : ((file.type == MediaType.image || file.type == MediaType.gif) && + ms.storedPath.existsSync() && + ms.storedPath.lengthSync() > 0) + ? ms.storedPath + : (ms.tempPath.existsSync() && + ms.tempPath.lengthSync() > 0 && + !isVideo) + ? ms.tempPath + : null; + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 60, + height: 60, + color: Colors.grey.withValues(alpha: 0.1), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: imgFile != null + ? Image.file( + imgFile, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.broken_image_outlined, + size: 24, + color: Colors.grey, + ), + ) + : file.blurhash != null + ? BlurHash( + hash: file.blurhash!, + optimizationMode: + BlurHashOptimizationMode.approximation, + ) + : const Icon( + Icons.image_outlined, + size: 24, + color: Colors.grey, + ), + ), + if (isVideo) + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(4), + child: const Icon( + Icons.play_arrow, + size: 16, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } + + Future _deleteMemory(BuildContext context, MediaFile file) async { + final hasCloudBackup = file.cloudState == CloudState.uploaded; + + final deleteCompletely = await showDeleteMemoriesDialog( + context: context, + count: 1, + hasCloudBackup: hasCloudBackup, + ); + + if (deleteCompletely == null) return; + + try { + MediaFileService(file).fullMediaRemoval(); + await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId); + + if (deleteCompletely) { + unawaited(apiService.deleteMemory(file.mediaId)); + } + + if (context.mounted) { + showSnackbar( + context, + context.lang.memoriesDeleteSnackbarSuccess(1), + level: SnackbarLevel.success, + ); + } + } catch (e) { + if (context.mounted) { + showSnackbar( + context, + e.toString(), + ); + } + } + } + + Future _batchDelete(BuildContext context, List allFiles) async { + final selectedCount = _selectedMediaIds.length; + if (selectedCount == 0) return; + + final selectedFiles = allFiles + .where((file) => _selectedMediaIds.contains(file.mediaId)) + .toList(); + final hasAnyCloudBackup = + selectedFiles.any((file) => file.cloudState == CloudState.uploaded); + + final deleteCompletely = await showDeleteMemoriesDialog( + context: context, + count: selectedCount, + hasCloudBackup: hasAnyCloudBackup, + ); + + if (deleteCompletely == null) return; + + try { + for (final file in selectedFiles) { + MediaFileService(file).fullMediaRemoval(); + await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId); + + if (deleteCompletely) { + unawaited(apiService.deleteMemory(file.mediaId)); + } + } + + setState(_selectedMediaIds.clear); + + if (context.mounted) { + showSnackbar( + context, + context.lang.memoriesDeleteSnackbarSuccess(selectedCount), + level: SnackbarLevel.success, + ); + } + } catch (e) { + if (context.mounted) { + showSnackbar( + context, + e.toString(), + ); + } + } + } +}