From 5914b81845e8f8639d245b3b58b09eac2397e74e Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 15 Sep 2026 23:43:23 +0200 Subject: [PATCH] add storage by chats view --- lib/src/database/daos/mediafiles.dao.dart | 49 +++++ .../generated/app_localizations.dart | 30 +++ .../generated/app_localizations_de.dart | 32 +++ .../generated/app_localizations_en.dart | 32 +++ lib/src/localization/translations/de.arb | 19 ++ lib/src/localization/translations/en.arb | 19 ++ .../chat_storage_overview.view.dart | 187 ++++++++++++++++++ .../data_and_storage/manage_storage.view.dart | 11 ++ test/drift/mediafiles_dao_test.dart | 62 ++++++ 9 files changed, 441 insertions(+) create mode 100644 lib/src/visual/views/settings/data_and_storage/chat_storage_overview.view.dart create mode 100644 test/drift/mediafiles_dao_test.dart diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 6b8a5b71..d603a677 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -10,6 +10,16 @@ import 'package:twonly/src/utils/log.dart'; part 'mediafiles.dao.g.dart'; +class ChatMediaFiles { + const ChatMediaFiles({ + required this.group, + required this.mediaFiles, + }); + + final Group group; + final List mediaFiles; +} + @DriftAccessor(tables: [MediaFiles, Messages]) class MediaFilesDao extends DatabaseAccessor with _$MediaFilesDaoMixin { @@ -165,6 +175,45 @@ class MediaFilesDao extends DatabaseAccessor return query.map((row) => row.readTable(mediaFiles)).watch(); } + /// Watches all chats and their distinct referenced media records. + Stream> watchMediaFilesByChat() { + final query = select(db.groups).join([ + leftOuterJoin( + db.messages, + db.messages.groupId.equalsExp(db.groups.groupId), + useColumns: false, + ), + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(db.messages.mediaId), + ), + ]); + + return query.watch().map((rows) { + final groupsById = {}; + final mediaByGroupId = >{}; + + for (final row in rows) { + final group = row.readTable(db.groups); + groupsById[group.groupId] = group; + final mediaFile = row.readTableOrNull(mediaFiles); + if (mediaFile != null) { + (mediaByGroupId[group.groupId] ??= {})[mediaFile.mediaId] = mediaFile; + } + } + + return groupsById.entries + .map( + (entry) => ChatMediaFiles( + group: entry.value, + mediaFiles: + mediaByGroupId[entry.key]?.values.toList() ?? const [], + ), + ) + .toList(); + }); + } + Stream> watchChatListMediaFiles() { return customSelect( ''' diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 32714005..fbafccaf 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4496,6 +4496,36 @@ abstract class AppLocalizations { /// **'Local only'** String get settingsStorageLocalOnly; + /// No description provided for @settingsStorageByChat. + /// + /// In en, this message translates to: + /// **'Storage by chat'** + String get settingsStorageByChat; + + /// No description provided for @settingsStorageTemporaryMediaDescription. + /// + /// In en, this message translates to: + /// **'Temporary media files are sent media files that have not been stored. They remain on your device until the receiver has received and opened them.'** + String get settingsStorageTemporaryMediaDescription; + + /// No description provided for @settingsStorageNoChats. + /// + /// In en, this message translates to: + /// **'No chats found.'** + String get settingsStorageNoChats; + + /// No description provided for @settingsStorageStoredMediaCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1 {1 stored file} other {{count} stored files}}'** + String settingsStorageStoredMediaCount(num count); + + /// No description provided for @settingsStorageTemporaryMediaCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1 {1 temporary file} other {{count} temporary files}}'** + String settingsStorageTemporaryMediaCount(num count); + /// No description provided for @settingsStorageDisableBackupTitle. /// /// 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 bf76487c..5d3e1cae 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2604,6 +2604,38 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsStorageLocalOnly => 'Nur lokal'; + @override + String get settingsStorageByChat => 'Speicherplatz pro Chat'; + + @override + String get settingsStorageTemporaryMediaDescription => + 'Temporäre Mediendateien sind gesendete Medien, die nicht gespeichert wurden. Sie bleiben auf deinem Gerät, bis der Empfänger sie erhalten und geöffnet hat.'; + + @override + String get settingsStorageNoChats => 'Keine Chats gefunden.'; + + @override + String settingsStorageStoredMediaCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count gespeicherte Dateien', + one: '1 gespeicherte Datei', + ); + return '$_temp0'; + } + + @override + String settingsStorageTemporaryMediaCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count temporäre Dateien', + one: '1 temporäre Datei', + ); + return '$_temp0'; + } + @override String get settingsStorageDisableBackupTitle => 'Backup deaktivieren?'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 37ee164d..2b83bc1b 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2581,6 +2581,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsStorageLocalOnly => 'Local only'; + @override + String get settingsStorageByChat => 'Storage by chat'; + + @override + String get settingsStorageTemporaryMediaDescription => + 'Temporary media files are sent media files that have not been stored. They remain on your device until the receiver has received and opened them.'; + + @override + String get settingsStorageNoChats => 'No chats found.'; + + @override + String settingsStorageStoredMediaCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count stored files', + one: '1 stored file', + ); + return '$_temp0'; + } + + @override + String settingsStorageTemporaryMediaCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count temporary files', + one: '1 temporary file', + ); + return '$_temp0'; + } + @override String get settingsStorageDisableBackupTitle => 'Disable Backup?'; diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 43ebbabf..5773b83b 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -989,6 +989,25 @@ "settingsStorageCloudOnly": "Nur Cloud", "settingsStorageLocalAndCloud": "Lokal & Cloud", "settingsStorageLocalOnly": "Nur lokal", + "settingsStorageByChat": "Speicherplatz pro Chat", + "settingsStorageTemporaryMediaDescription": "Temporäre Mediendateien sind gesendete Medien, die nicht gespeichert wurden. Sie bleiben auf deinem Gerät, bis der Empfänger sie erhalten und geöffnet hat.", + "settingsStorageNoChats": "Keine Chats gefunden.", + "settingsStorageStoredMediaCount": "{count, plural, =1 {1 gespeicherte Datei} other {{count} gespeicherte Dateien}}", + "@settingsStorageStoredMediaCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "settingsStorageTemporaryMediaCount": "{count, plural, =1 {1 temporäre Datei} other {{count} temporäre Dateien}}", + "@settingsStorageTemporaryMediaCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, "settingsStorageDisableBackupTitle": "Backup deaktivieren?", "settingsStorageDisableBackupBody": "Dadurch werden alle gesicherten Erinnerungen aus der Cloud gelöscht. Es gibt {count, plural, =1 {*1 Bild*, das} other {*{count} Bilder*, die}} nur in der Cloud und nicht lokal gespeichert sind. Diese gehen *dauerhaft verloren*. Fortfahren?", "@settingsStorageDisableBackupBody": { diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 0a4108a2..c580170d 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -999,6 +999,25 @@ "settingsStorageCloudOnly": "Cloud only", "settingsStorageLocalAndCloud": "Local and cloud", "settingsStorageLocalOnly": "Local only", + "settingsStorageByChat": "Storage by chat", + "settingsStorageTemporaryMediaDescription": "Temporary media files are sent media files that have not been stored. They remain on your device until the receiver has received and opened them.", + "settingsStorageNoChats": "No chats found.", + "settingsStorageStoredMediaCount": "{count, plural, =1 {1 stored file} other {{count} stored files}}", + "@settingsStorageStoredMediaCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "settingsStorageTemporaryMediaCount": "{count, plural, =1 {1 temporary file} other {{count} temporary files}}", + "@settingsStorageTemporaryMediaCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, "settingsStorageDisableBackupTitle": "Disable Backup?", "settingsStorageDisableBackupBody": "This will delete all backed up memories from the cloud. There {count, plural, =1 {is *1 image*} other {are *{count} images*}} only stored in the cloud that will be *permanently lost*. Are you sure you want to proceed?", "@settingsStorageDisableBackupBody": { diff --git a/lib/src/visual/views/settings/data_and_storage/chat_storage_overview.view.dart b/lib/src/visual/views/settings/data_and_storage/chat_storage_overview.view.dart new file mode 100644 index 00000000..8dd2d4b2 --- /dev/null +++ b/lib/src/visual/views/settings/data_and_storage/chat_storage_overview.view.dart @@ -0,0 +1,187 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/daos/mediafiles.dao.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/avatar_icon.comp.dart'; + +class ChatStorageOverviewView extends StatefulWidget { + const ChatStorageOverviewView({super.key}); + + @override + State createState() => + _ChatStorageOverviewViewState(); +} + +class _ChatStorageOverviewViewState extends State { + late final Stream> _chatMediaFilesStream; + + @override + void initState() { + super.initState(); + _chatMediaFilesStream = twonlyDB.mediaFilesDao.watchMediaFilesByChat(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.lang.settingsStorageByChat), + ), + body: StreamBuilder>( + stream: _chatMediaFilesStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + + final summaries = + (snapshot.data ?? []) + .map(_ChatStorageSummary.fromChatMediaFiles) + .toList() + ..sort((a, b) { + final sizeComparison = b.totalBytes.compareTo(a.totalBytes); + if (sizeComparison != 0) return sizeComparison; + return a.group.groupName.toLowerCase().compareTo( + b.group.groupName.toLowerCase(), + ); + }); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _TemporaryMediaExplanation( + text: context.lang.settingsStorageTemporaryMediaDescription, + ), + const SizedBox(height: 16), + if (summaries.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + context.lang.settingsStorageNoChats, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ) + else + ...summaries.map( + (summary) => ListTile( + contentPadding: EdgeInsets.zero, + leading: AvatarIcon(group: summary.group), + title: Text(summary.group.groupName), + subtitle: Text( + '${context.lang.settingsStorageStoredMediaCount(summary.storedMediaCount)}' + ' • ' + '${context.lang.settingsStorageTemporaryMediaCount(summary.temporaryMediaCount)}', + ), + trailing: Text( + formatBytes(summary.totalBytes), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _TemporaryMediaExplanation extends StatelessWidget { + const _TemporaryMediaExplanation({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline_rounded, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded(child: Text(text)), + ], + ), + ); + } +} + +class _ChatStorageSummary { + const _ChatStorageSummary({ + required this.group, + required this.storedBytes, + required this.temporaryBytes, + required this.storedMediaCount, + required this.temporaryMediaCount, + }); + + factory _ChatStorageSummary.fromChatMediaFiles(ChatMediaFiles chat) { + var storedBytes = 0; + var temporaryBytes = 0; + var storedMediaCount = 0; + var temporaryMediaCount = 0; + + for (final mediaFile in chat.mediaFiles) { + final mediaFileService = MediaFileService(mediaFile); + final storedSize = _fileSize(mediaFileService.storedPath); + final temporarySize = _fileSize(mediaFileService.tempPath); + + if (storedSize > 0) { + storedMediaCount++; + storedBytes += storedSize; + } + if (temporarySize > 0) { + temporaryMediaCount++; + temporaryBytes += temporarySize; + } + } + + return _ChatStorageSummary( + group: chat.group, + storedBytes: storedBytes, + temporaryBytes: temporaryBytes, + storedMediaCount: storedMediaCount, + temporaryMediaCount: temporaryMediaCount, + ); + } + + final Group group; + final int storedBytes; + final int temporaryBytes; + final int storedMediaCount; + final int temporaryMediaCount; + + int get totalBytes => storedBytes + temporaryBytes; + + static int _fileSize(File file) { + try { + if (!file.existsSync()) return 0; + return file.lengthSync(); + } on FileSystemException { + return 0; + } + } +} 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 2e3d1258..c2166acc 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 @@ -8,6 +8,7 @@ import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.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/chat_storage_overview.view.dart'; import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart'; class ManageStorageView extends StatefulWidget { @@ -179,6 +180,16 @@ class _ManageStorageViewState extends State { child: Text(context.lang.settingsStorageContents), ), ), + const SizedBox(height: 12), + Align( + child: MyButton( + variant: MyButtonVariant.secondaryMiddle, + onPressed: () => context.navPush( + const ChatStorageOverviewView(), + ), + child: Text(context.lang.settingsStorageByChat), + ), + ), ], ), ); diff --git a/test/drift/mediafiles_dao_test.dart b/test/drift/mediafiles_dao_test.dart new file mode 100644 index 00000000..cf38b2db --- /dev/null +++ b/test/drift/mediafiles_dao_test.dart @@ -0,0 +1,62 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +void main() { + late TwonlyDB database; + + setUp(() { + database = TwonlyDB(NativeDatabase.memory()); + }); + + tearDown(() => database.close()); + + test( + 'chat media includes empty groups and deduplicates message references', + () async { + await database.customStatement( + "INSERT INTO groups(group_id, group_name) VALUES('chat-1', 'Friends')", + ); + await database.customStatement( + "INSERT INTO groups(group_id, group_name) VALUES('chat-2', 'Family')", + ); + await database.customStatement( + "INSERT INTO groups(group_id, group_name) VALUES('chat-3', 'Empty')", + ); + await database.customStatement( + "INSERT INTO media_files(media_id, type, stored, size_in_bytes) " + "VALUES('not-stored', 'image', 0, 100)", + ); + await database.customStatement( + "INSERT INTO media_files(media_id, type, stored, size_in_bytes) " + "VALUES('stored', 'video', 1, 200)", + ); + await database.customStatement( + "INSERT INTO messages(group_id, message_id, type, media_id) " + "VALUES('chat-1', 'message-1', 'media', 'not-stored')", + ); + await database.customStatement( + "INSERT INTO messages(group_id, message_id, type, media_id) " + "VALUES('chat-1', 'message-2', 'media', 'not-stored')", + ); + await database.customStatement( + "INSERT INTO messages(group_id, message_id, type, media_id) " + "VALUES('chat-2', 'message-3', 'media', 'stored')", + ); + + final chats = await database.mediaFilesDao.watchMediaFilesByChat().first; + + expect(chats, hasLength(3)); + final friends = chats.singleWhere( + (chat) => chat.group.groupId == 'chat-1', + ); + expect(friends.mediaFiles, hasLength(1)); + expect(friends.mediaFiles.single.mediaId, 'not-stored'); + expect(friends.mediaFiles.single.stored, isFalse); + final empty = chats.singleWhere( + (chat) => chat.group.groupId == 'chat-3', + ); + expect(empty.mediaFiles, isEmpty); + }, + ); +}