mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
add storage by chats view
This commit is contained in:
parent
5786effc5b
commit
5914b81845
9 changed files with 441 additions and 0 deletions
|
|
@ -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<MediaFile> mediaFiles;
|
||||
}
|
||||
|
||||
@DriftAccessor(tables: [MediaFiles, Messages])
|
||||
class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||
with _$MediaFilesDaoMixin {
|
||||
|
|
@ -165,6 +175,45 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
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() {
|
||||
return customSelect(
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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?';
|
||||
|
||||
|
|
|
|||
|
|
@ -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?';
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ManageStorageView> {
|
|||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
62
test/drift/mediafiles_dao_test.dart
Normal file
62
test/drift/mediafiles_dao_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue