From e9b550023f9d074e23e34398d2ca20c4202462dc Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 16 May 2026 16:52:19 +0200 Subject: [PATCH] New: Manage storage view --- CHANGELOG.md | 3 +- lib/src/constants/routes.keys.dart | 1 + lib/src/database/daos/mediafiles.dao.dart | 13 + .../generated/app_localizations.dart | 30 +++ .../generated/app_localizations_de.dart | 15 ++ .../generated/app_localizations_en.dart | 15 ++ lib/src/localization/translations | 2 +- lib/src/providers/routing.provider.dart | 5 + .../services/memories/memories.service.dart | 244 ++++++++---------- .../views/settings/data_and_storage.view.dart | 17 ++ .../data_and_storage/manage_storage.view.dart | 155 +++++++++++ 11 files changed, 355 insertions(+), 145 deletions(-) create mode 100644 lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index afa488f7..4322ba57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 0.2.13 -- Improved: Media thumbnails for faster loading +- New: Manage storage view. +- Improved: Media thumbnails for faster loading. ## 0.2.12 diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index d6690619..2835d4e1 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -37,6 +37,7 @@ class Routes { '/settings/privacy/user_discovery'; static const String settingsNotification = '/settings/notification'; static const String settingsStorage = '/settings/storage_data'; + static const String settingsStorageManage = '/settings/storage_data/manage'; static const String settingsStorageImport = '/settings/storage_data/import'; static const String settingsStorageExport = '/settings/storage_data/export'; static const String settingsHelp = '/settings/help'; diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 1194643b..d22d3a80 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -184,4 +184,17 @@ class MediaFilesDao extends DatabaseAccessor final rows = await query.get(); return rows.map((row) => row.readTable(db.messages).messageId).toList(); } + + Future> getStorageStats() async { + final rows = await select(mediaFiles).get(); + final stats = {}; + + for (final row in rows) { + final type = row.type; + 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 1b67a9fe..ab5544fa 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -542,6 +542,36 @@ abstract class AppLocalizations { /// **'When using WI-FI'** String get settingsStorageDataAutoDownWifi; + /// No description provided for @settingsStorageManageTitle. + /// + /// In en, this message translates to: + /// **'Manage storage'** + String get settingsStorageManageTitle; + + /// No description provided for @settingsStorageUsed. + /// + /// In en, this message translates to: + /// **'Storage used'** + String get settingsStorageUsed; + + /// No description provided for @settingsStorageImages. + /// + /// In en, this message translates to: + /// **'Images'** + String get settingsStorageImages; + + /// No description provided for @settingsStorageVideos. + /// + /// In en, this message translates to: + /// **'Videos'** + String get settingsStorageVideos; + + /// No description provided for @settingsStorageGifs. + /// + /// In en, this message translates to: + /// **'GIFs'** + String get settingsStorageGifs; + /// No description provided for @settingsProfileCustomizeAvatar. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index bc08654f..4f2fd5aa 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -249,6 +249,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsStorageDataAutoDownWifi => 'Bei Nutzung von WLAN'; + @override + String get settingsStorageManageTitle => 'Speicher verwalten'; + + @override + String get settingsStorageUsed => 'Speicherplatz belegt'; + + @override + String get settingsStorageImages => 'Bilder'; + + @override + String get settingsStorageVideos => 'Videos'; + + @override + String get settingsStorageGifs => 'GIFs'; + @override String get settingsProfileCustomizeAvatar => 'Avatar anpassen'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index caaee9b6..ec6cdb9b 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -245,6 +245,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsStorageDataAutoDownWifi => 'When using WI-FI'; + @override + String get settingsStorageManageTitle => 'Manage storage'; + + @override + String get settingsStorageUsed => 'Storage used'; + + @override + String get settingsStorageImages => 'Images'; + + @override + String get settingsStorageVideos => 'Videos'; + + @override + String get settingsStorageGifs => 'GIFs'; + @override String get settingsProfileCustomizeAvatar => 'Customize your avatar'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index f649128f..63f73aec 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit f649128fd875a12f23518ff2641190cc129a9339 +Subproject commit 63f73aec47d84009ca50d2e8ad324b11eff59e9d diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 648ba97f..129ba449 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -23,6 +23,7 @@ import 'package:twonly/src/visual/views/settings/chat/chat_settings.view.dart'; import 'package:twonly/src/visual/views/settings/data_and_storage.view.dart'; import 'package:twonly/src/visual/views/settings/data_and_storage/export_media.view.dart'; import 'package:twonly/src/visual/views/settings/data_and_storage/import_media.view.dart'; +import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart'; import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart'; import 'package:twonly/src/visual/views/settings/developer/developer.view.dart'; import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart'; @@ -210,6 +211,10 @@ final routerProvider = GoRouter( path: 'storage_data', builder: (context, state) => const DataAndStorageView(), routes: [ + GoRoute( + path: 'manage', + builder: (context, state) => const ManageStorageView(), + ), GoRoute( path: 'import', builder: (context, state) => const ImportMediaView(), diff --git a/lib/src/services/memories/memories.service.dart b/lib/src/services/memories/memories.service.dart index 35ae1e7b..26a3016a 100644 --- a/lib/src/services/memories/memories.service.dart +++ b/lib/src/services/memories/memories.service.dart @@ -89,14 +89,10 @@ class MemoriesService { final mediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds( mediaIds, ); - final mediaFileMap = {for (final m in mediaFiles) m.mediaId: m}; final allContacts = await twonlyDB.contactsDao.getAllContacts(); final contactMap = {for (final c in allContacts) c.userId: c}; - - final now = clock.now(); - final tempGalleryItems = []; - final tempGalleryItemsLastYears = >{}; + final mediaIdToSender = {}; for (final itemJson in itemList) { final map = itemJson as Map; @@ -104,64 +100,14 @@ class MemoriesService { final senderUserId = map['senderUserId'] as int?; if (mediaId == null) continue; - final mediaFile = mediaFileMap[mediaId]; - if (mediaFile == null) continue; - - final mediaService = MediaFileService(mediaFile); - if (!mediaService.imagePreviewAvailable) continue; - - final contact = senderUserId != null + mediaIdToSender[mediaId] = senderUserId != null ? contactMap[senderUserId] : null; - final item = MemoryItem( - mediaService: mediaService, - messages: [], - sender: contact, - ); - tempGalleryItems.add(item); - - if (mediaFile.createdAt.month == now.month && - mediaFile.createdAt.day == now.day) { - final diff = now.year - mediaFile.createdAt.year; - if (diff > 0) { - tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item); - } - } } - final tempOrderedByMonth = >{}; - final tempMonths = []; - var lastMonth = ''; - - for (var i = 0; i < tempGalleryItems.length; i++) { - final mFile = tempGalleryItems[i].mediaService.mediaFile; - final month = - mFile.createdAtMonth ?? - DateFormat('MMMM yyyy').format(mFile.createdAt); - if (lastMonth != month) { - lastMonth = month; - tempMonths.add(month); - } - tempOrderedByMonth.putIfAbsent(month, () => []).add(i); - } - - for (final list in tempGalleryItemsLastYears.values) { - list.sort( - (a, b) => b.mediaService.mediaFile.createdAt.compareTo( - a.mediaService.mediaFile.createdAt, - ), - ); - } - - final sortedGalleryItemsLastYears = - SplayTreeMap>.from(tempGalleryItemsLastYears); - - _cachedState = MemoriesState( - filesToMigrate: 0, - galleryItems: tempGalleryItems, - months: tempMonths, - orderedByMonth: tempOrderedByMonth, - galleryItemsLastYears: sortedGalleryItemsLastYears, + _cachedState = _computeState( + mediaFiles: mediaFiles, + mediaIdToSender: mediaIdToSender, ); } } catch (e) { @@ -169,14 +115,87 @@ class MemoriesService { } } + static MemoriesState _computeState({ + required List mediaFiles, + required Map mediaIdToSender, + int filesToMigrate = 0, + }) { + final now = clock.now(); + final tempGalleryItems = []; + final tempGalleryItemsLastYears = >{}; + + for (final mediaFile in mediaFiles) { + final mediaService = MediaFileService(mediaFile); + if (!mediaService.imagePreviewAvailable) continue; + + final senderContact = mediaIdToSender[mediaFile.mediaId]; + final item = MemoryItem( + mediaService: mediaService, + messages: [], + sender: senderContact, + ); + + tempGalleryItems.add(item); + + if (mediaFile.createdAt.month == now.month && + mediaFile.createdAt.day == now.day) { + final diff = now.year - mediaFile.createdAt.year; + if (diff > 0) { + tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item); + } + } + } + + // Sort descending by creation date + tempGalleryItems.sort( + (a, b) => b.mediaService.mediaFile.createdAt.compareTo( + a.mediaService.mediaFile.createdAt, + ), + ); + + final tempOrderedByMonth = >{}; + final tempMonths = []; + var lastMonth = ''; + + for (var i = 0; i < tempGalleryItems.length; i++) { + final mFile = tempGalleryItems[i].mediaService.mediaFile; + final month = + mFile.createdAtMonth ?? + DateFormat('MMMM yyyy').format(mFile.createdAt); + if (lastMonth != month) { + lastMonth = month; + tempMonths.add(month); + } + tempOrderedByMonth.putIfAbsent(month, () => []).add(i); + } + + for (final list in tempGalleryItemsLastYears.values) { + list.sort( + (a, b) => b.mediaService.mediaFile.createdAt.compareTo( + a.mediaService.mediaFile.createdAt, + ), + ); + } + + final sortedGalleryItemsLastYears = + SplayTreeMap>.from(tempGalleryItemsLastYears); + + return MemoriesState( + filesToMigrate: filesToMigrate, + galleryItems: tempGalleryItems, + months: tempMonths, + orderedByMonth: tempOrderedByMonth, + galleryItemsLastYears: sortedGalleryItemsLastYears, + ); + } + Future _initAsync() async { try { - // 1. Perform Inventory / Migration of stored files final pendingFiles = await twonlyDB.mediaFilesDao .getAllMediaFilesPendingMigration(); if (pendingFiles.isNotEmpty) { - _updateState(filesToMigrate: pendingFiles.length); + _updateMigrationCount(pendingFiles.length); for (final mediaFile in pendingFiles) { final mediaService = MediaFileService(mediaFile); @@ -203,13 +222,12 @@ class MemoriesService { await mediaService.createThumbnail(); } } - _updateState(filesToMigrate: _currentState.filesToMigrate - 1); + _updateMigrationCount(_currentState.filesToMigrate - 1); } - _updateState(filesToMigrate: 0); + _updateMigrationCount(0); } - // 2. Subscribe to stored media files stream await _dbSubscription?.cancel(); _dbSubscription = twonlyDB.mediaFilesDao .watchAllStoredMediaFiles() @@ -221,11 +239,6 @@ class MemoriesService { Future _processMediaFilesStream(List mediaFiles) async { try { - final now = clock.now(); - final tempGalleryItems = []; - final tempGalleryItemsLastYears = >{}; - - // High-performance batch DB fetch for sender attribution via Messages table mapping final mediaIds = mediaFiles.map((m) => m.mediaId).toList(); final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds( mediaIds, @@ -244,81 +257,24 @@ class MemoriesService { } } - for (final mediaFile in mediaFiles) { - final mediaService = MediaFileService(mediaFile); - if (!mediaService.imagePreviewAvailable) continue; - - if (!mediaService.mediaFile.hasThumbnail && - mediaService.mediaFile.type != MediaType.audio) { - unawaited(mediaService.createThumbnail()); - } - - final senderContact = mediaIdToSenderContact[mediaFile.mediaId]; - final item = MemoryItem( - mediaService: mediaService, - messages: [], - sender: senderContact, - ); - - tempGalleryItems.add(item); - - if (mediaFile.createdAt.month == now.month && - mediaFile.createdAt.day == now.day) { - final diff = now.year - mediaFile.createdAt.year; - if (diff > 0) { - tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item); - } - } - } - - // Sort descending by creation date - tempGalleryItems.sort( - (a, b) => b.mediaService.mediaFile.createdAt.compareTo( - a.mediaService.mediaFile.createdAt, - ), - ); - - final tempOrderedByMonth = >{}; - final tempMonths = []; - var lastMonth = ''; - - // High performance grouping leveraging pre-computed createdAtMonth column - for (var i = 0; i < tempGalleryItems.length; i++) { - final mFile = tempGalleryItems[i].mediaService.mediaFile; - final month = - mFile.createdAtMonth ?? - DateFormat('MMMM yyyy').format(mFile.createdAt); - if (lastMonth != month) { - lastMonth = month; - tempMonths.add(month); - } - tempOrderedByMonth.putIfAbsent(month, () => []).add(i); - } - - for (final list in tempGalleryItemsLastYears.values) { - list.sort( - (a, b) => b.mediaService.mediaFile.createdAt.compareTo( - a.mediaService.mediaFile.createdAt, - ), - ); - } - - final sortedGalleryItemsLastYears = - SplayTreeMap>.from(tempGalleryItemsLastYears); - - final newState = MemoriesState( + final newState = _computeState( + mediaFiles: mediaFiles, + mediaIdToSender: mediaIdToSenderContact, filesToMigrate: _currentState.filesToMigrate, - galleryItems: tempGalleryItems, - months: tempMonths, - orderedByMonth: tempOrderedByMonth, - galleryItemsLastYears: sortedGalleryItemsLastYears, ); + for (final item in newState.galleryItems) { + if (!item.mediaService.mediaFile.hasThumbnail && + item.mediaService.mediaFile.type != MediaType.audio) { + unawaited(item.mediaService.createThumbnail()); + } + } + _cachedState = newState; - _updateStateWithObject(newState); + _updateState(newState); // Persist to KeyValueStore cache asynchronously - final cacheList = tempGalleryItems + final cacheList = newState.galleryItems .map( (item) => { 'mediaId': item.mediaService.mediaFile.mediaId, @@ -332,15 +288,17 @@ class MemoriesService { } } - void _updateStateWithObject(MemoriesState newState) { + void _updateState(MemoriesState newState) { _currentState = newState; - if (!_stateController.isClosed) { - _stateController.add(_currentState); - } + _notifyState(); } - void _updateState({int? filesToMigrate}) { + void _updateMigrationCount(int filesToMigrate) { _currentState = _currentState.copyWith(filesToMigrate: filesToMigrate); + _notifyState(); + } + + void _notifyState() { if (!_stateController.isClosed) { _stateController.add(_currentState); } diff --git a/lib/src/visual/views/settings/data_and_storage.view.dart b/lib/src/visual/views/settings/data_and_storage.view.dart index ce6a6b22..16a38c1d 100644 --- a/lib/src/visual/views/settings/data_and_storage.view.dart +++ b/lib/src/visual/views/settings/data_and_storage.view.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.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/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -64,6 +65,22 @@ class _DataAndStorageViewState extends State { defaultAutoDownloadOptions; return ListView( children: [ + FutureBuilder>( + future: twonlyDB.mediaFilesDao.getStorageStats(), + builder: (context, snapshot) { + final stats = snapshot.data ?? {}; + final totalBytes = stats.values.fold(0, (a, b) => a + b); + final sizeStr = formatBytes(totalBytes); + + return ListTile( + title: Text(context.lang.settingsStorageManageTitle), + subtitle: Text(sizeStr), + onTap: () => context.push(Routes.settingsStorageManage), + trailing: const Icon(Icons.chevron_right), + ); + }, + ), + const Divider(), ListTile( title: Text(context.lang.settingsStorageDataStoreInGTitle), subtitle: Text( 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 new file mode 100644 index 00000000..73308b3b --- /dev/null +++ b/lib/src/visual/views/settings/data_and_storage/manage_storage.view.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/utils/misc.dart'; + +class ManageStorageView extends StatefulWidget { + const ManageStorageView({super.key}); + + @override + State createState() => _ManageStorageViewState(); +} + +class _ManageStorageViewState extends State { + Map _stats = {}; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + Future _loadStats() async { + final stats = await twonlyDB.mediaFilesDao.getStorageStats(); + if (mounted) { + setState(() { + _stats = stats; + }); + } + } + + @override + Widget build(BuildContext context) { + final totalBytes = _stats.entries + .where((e) => e.key != MediaType.audio) + .fold(0, (a, b) => a + b.value); + final imageBytes = _stats[MediaType.image] ?? 0; + final videoBytes = _stats[MediaType.video] ?? 0; + final gifBytes = _stats[MediaType.gif] ?? 0; + + return Scaffold( + appBar: AppBar( + title: Text(context.lang.settingsStorageManageTitle), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + context.lang.settingsStorageUsed, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + formatBytes(totalBytes), + 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 (totalBytes == 0) return const SizedBox.shrink(); + + final maxWidth = constraints.maxWidth; + final imageWidth = (imageBytes / totalBytes) * maxWidth; + final videoWidth = (videoBytes / totalBytes) * maxWidth; + final gifWidth = (gifBytes / totalBytes) * maxWidth; + + return Row( + children: [ + if (imageBytes > 0) + Container(width: imageWidth, color: Colors.blue), + if (videoBytes > 0) + Container(width: videoWidth, color: Colors.green), + if (gifBytes > 0) + Container(width: gifWidth, color: Colors.orange), + ], + ); + }, + ), + ), + ), + const SizedBox(height: 24), + _StorageCategoryTile( + title: context.lang.settingsStorageImages, + size: formatBytes(imageBytes), + color: Colors.blue, + ), + _StorageCategoryTile( + title: context.lang.settingsStorageVideos, + size: formatBytes(videoBytes), + color: Colors.green, + ), + _StorageCategoryTile( + title: context.lang.settingsStorageGifs, + size: formatBytes(gifBytes), + color: Colors.orange, + ), + ], + ), + ); + } +} + +class _StorageCategoryTile extends StatelessWidget { + const _StorageCategoryTile({ + required this.title, + required this.size, + required this.color, + }); + final String title; + final String size; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 16), + ), + ), + Text( + size, + style: const TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ); + } +}