add storage by chats view

This commit is contained in:
otsmr 2026-09-15 23:43:23 +02:00
parent 5786effc5b
commit 5914b81845
9 changed files with 441 additions and 0 deletions

View file

@ -10,6 +10,16 @@ import 'package:twonly/src/utils/log.dart';
part 'mediafiles.dao.g.dart'; part 'mediafiles.dao.g.dart';
class ChatMediaFiles {
const ChatMediaFiles({
required this.group,
required this.mediaFiles,
});
final Group group;
final List<MediaFile> mediaFiles;
}
@DriftAccessor(tables: [MediaFiles, Messages]) @DriftAccessor(tables: [MediaFiles, Messages])
class MediaFilesDao extends DatabaseAccessor<TwonlyDB> class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
with _$MediaFilesDaoMixin { with _$MediaFilesDaoMixin {
@ -165,6 +175,45 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
return query.map((row) => row.readTable(mediaFiles)).watch(); return query.map((row) => row.readTable(mediaFiles)).watch();
} }
/// Watches all chats and their distinct referenced media records.
Stream<List<ChatMediaFiles>> 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 = <String, Group>{};
final mediaByGroupId = <String, Map<String, MediaFile>>{};
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<List<MediaFile>> watchChatListMediaFiles() { Stream<List<MediaFile>> watchChatListMediaFiles() {
return customSelect( return customSelect(
''' '''

View file

@ -4496,6 +4496,36 @@ abstract class AppLocalizations {
/// **'Local only'** /// **'Local only'**
String get settingsStorageLocalOnly; 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. /// No description provided for @settingsStorageDisableBackupTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2604,6 +2604,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsStorageLocalOnly => 'Nur lokal'; 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 @override
String get settingsStorageDisableBackupTitle => 'Backup deaktivieren?'; String get settingsStorageDisableBackupTitle => 'Backup deaktivieren?';

View file

@ -2581,6 +2581,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsStorageLocalOnly => 'Local only'; 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 @override
String get settingsStorageDisableBackupTitle => 'Disable Backup?'; String get settingsStorageDisableBackupTitle => 'Disable Backup?';

View file

@ -989,6 +989,25 @@
"settingsStorageCloudOnly": "Nur Cloud", "settingsStorageCloudOnly": "Nur Cloud",
"settingsStorageLocalAndCloud": "Lokal & Cloud", "settingsStorageLocalAndCloud": "Lokal & Cloud",
"settingsStorageLocalOnly": "Nur lokal", "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?", "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": "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": { "@settingsStorageDisableBackupBody": {

View file

@ -999,6 +999,25 @@
"settingsStorageCloudOnly": "Cloud only", "settingsStorageCloudOnly": "Cloud only",
"settingsStorageLocalAndCloud": "Local and cloud", "settingsStorageLocalAndCloud": "Local and cloud",
"settingsStorageLocalOnly": "Local only", "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?", "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": "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": { "@settingsStorageDisableBackupBody": {

View file

@ -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<ChatStorageOverviewView> createState() =>
_ChatStorageOverviewViewState();
}
class _ChatStorageOverviewViewState extends State<ChatStorageOverviewView> {
late final Stream<List<ChatMediaFiles>> _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<List<ChatMediaFiles>>(
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;
}
}
}

View file

@ -8,6 +8,7 @@ import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/chat_storage_overview.view.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart'; import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart';
class ManageStorageView extends StatefulWidget { class ManageStorageView extends StatefulWidget {
@ -179,6 +180,16 @@ class _ManageStorageViewState extends State<ManageStorageView> {
child: Text(context.lang.settingsStorageContents), 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),
),
),
], ],
), ),
); );

View file

@ -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);
},
);
}