New: Manage storage view
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-05-16 16:52:19 +02:00
parent 5556532879
commit e9b550023f
11 changed files with 355 additions and 145 deletions

View file

@ -2,7 +2,8 @@
## 0.2.13 ## 0.2.13
- Improved: Media thumbnails for faster loading - New: Manage storage view.
- Improved: Media thumbnails for faster loading.
## 0.2.12 ## 0.2.12

View file

@ -37,6 +37,7 @@ class Routes {
'/settings/privacy/user_discovery'; '/settings/privacy/user_discovery';
static const String settingsNotification = '/settings/notification'; static const String settingsNotification = '/settings/notification';
static const String settingsStorage = '/settings/storage_data'; 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 settingsStorageImport = '/settings/storage_data/import';
static const String settingsStorageExport = '/settings/storage_data/export'; static const String settingsStorageExport = '/settings/storage_data/export';
static const String settingsHelp = '/settings/help'; static const String settingsHelp = '/settings/help';

View file

@ -184,4 +184,17 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
final rows = await query.get(); final rows = await query.get();
return rows.map((row) => row.readTable(db.messages).messageId).toList(); return rows.map((row) => row.readTable(db.messages).messageId).toList();
} }
Future<Map<MediaType, int>> getStorageStats() async {
final rows = await select(mediaFiles).get();
final stats = <MediaType, int>{};
for (final row in rows) {
final type = row.type;
final size = row.sizeInBytes ?? 0;
stats[type] = (stats[type] ?? 0) + size;
}
return stats;
}
} }

View file

@ -542,6 +542,36 @@ abstract class AppLocalizations {
/// **'When using WI-FI'** /// **'When using WI-FI'**
String get settingsStorageDataAutoDownWifi; 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. /// No description provided for @settingsProfileCustomizeAvatar.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -249,6 +249,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsStorageDataAutoDownWifi => 'Bei Nutzung von WLAN'; 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 @override
String get settingsProfileCustomizeAvatar => 'Avatar anpassen'; String get settingsProfileCustomizeAvatar => 'Avatar anpassen';

View file

@ -245,6 +245,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsStorageDataAutoDownWifi => 'When using WI-FI'; 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 @override
String get settingsProfileCustomizeAvatar => 'Customize your avatar'; String get settingsProfileCustomizeAvatar => 'Customize your avatar';

@ -1 +1 @@
Subproject commit f649128fd875a12f23518ff2641190cc129a9339 Subproject commit 63f73aec47d84009ca50d2e8ad324b11eff59e9d

View file

@ -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.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/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/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/automated_testing.view.dart';
import 'package:twonly/src/visual/views/settings/developer/developer.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'; import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart';
@ -210,6 +211,10 @@ final routerProvider = GoRouter(
path: 'storage_data', path: 'storage_data',
builder: (context, state) => const DataAndStorageView(), builder: (context, state) => const DataAndStorageView(),
routes: [ routes: [
GoRoute(
path: 'manage',
builder: (context, state) => const ManageStorageView(),
),
GoRoute( GoRoute(
path: 'import', path: 'import',
builder: (context, state) => const ImportMediaView(), builder: (context, state) => const ImportMediaView(),

View file

@ -89,14 +89,10 @@ class MemoriesService {
final mediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds( final mediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds(
mediaIds, mediaIds,
); );
final mediaFileMap = {for (final m in mediaFiles) m.mediaId: m};
final allContacts = await twonlyDB.contactsDao.getAllContacts(); final allContacts = await twonlyDB.contactsDao.getAllContacts();
final contactMap = {for (final c in allContacts) c.userId: c}; final contactMap = {for (final c in allContacts) c.userId: c};
final mediaIdToSender = <String, Contact?>{};
final now = clock.now();
final tempGalleryItems = <MemoryItem>[];
final tempGalleryItemsLastYears = <int, List<MemoryItem>>{};
for (final itemJson in itemList) { for (final itemJson in itemList) {
final map = itemJson as Map<String, dynamic>; final map = itemJson as Map<String, dynamic>;
@ -104,64 +100,14 @@ class MemoriesService {
final senderUserId = map['senderUserId'] as int?; final senderUserId = map['senderUserId'] as int?;
if (mediaId == null) continue; if (mediaId == null) continue;
final mediaFile = mediaFileMap[mediaId]; mediaIdToSender[mediaId] = senderUserId != null
if (mediaFile == null) continue;
final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue;
final contact = senderUserId != null
? contactMap[senderUserId] ? contactMap[senderUserId]
: null; : 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 = <String, List<int>>{}; _cachedState = _computeState(
final tempMonths = <String>[]; mediaFiles: mediaFiles,
var lastMonth = ''; mediaIdToSender: mediaIdToSender,
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<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
_cachedState = MemoriesState(
filesToMigrate: 0,
galleryItems: tempGalleryItems,
months: tempMonths,
orderedByMonth: tempOrderedByMonth,
galleryItemsLastYears: sortedGalleryItemsLastYears,
); );
} }
} catch (e) { } catch (e) {
@ -169,91 +115,20 @@ class MemoriesService {
} }
} }
Future<void> _initAsync() async { static MemoriesState _computeState({
try { required List<MediaFile> mediaFiles,
// 1. Perform Inventory / Migration of stored files required Map<String, Contact?> mediaIdToSender,
final pendingFiles = await twonlyDB.mediaFilesDao int filesToMigrate = 0,
.getAllMediaFilesPendingMigration(); }) {
if (pendingFiles.isNotEmpty) {
_updateState(filesToMigrate: pendingFiles.length);
for (final mediaFile in pendingFiles) {
final mediaService = MediaFileService(mediaFile);
if (mediaService.mediaFile.storedFileHash == null) {
await mediaService.hashMediaFile();
}
if (!mediaService.mediaFile.hasCropAnalyzed) {
await mediaService.cropTransparentBorders();
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
}
if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync()) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
} else if (mediaFile.type != MediaType.audio) {
await mediaService.createThumbnail();
}
}
_updateState(filesToMigrate: _currentState.filesToMigrate - 1);
}
_updateState(filesToMigrate: 0);
}
// 2. Subscribe to stored media files stream
await _dbSubscription?.cancel();
_dbSubscription = twonlyDB.mediaFilesDao
.watchAllStoredMediaFiles()
.listen(_processMediaFilesStream);
} catch (e) {
Log.error('Error initializing MemoriesService: $e');
}
}
Future<void> _processMediaFilesStream(List<MediaFile> mediaFiles) async {
try {
final now = clock.now(); final now = clock.now();
final tempGalleryItems = <MemoryItem>[]; final tempGalleryItems = <MemoryItem>[];
final tempGalleryItemsLastYears = <int, List<MemoryItem>>{}; final tempGalleryItemsLastYears = <int, List<MemoryItem>>{};
// 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,
);
final allContacts = await twonlyDB.contactsDao.getAllContacts();
final contactMap = {for (final c in allContacts) c.userId: c};
final mediaIdToSenderContact = <String, Contact>{};
for (final msg in allMessages) {
if (msg.mediaId != null && msg.senderId != null) {
final contact = contactMap[msg.senderId];
if (contact != null) {
mediaIdToSenderContact[msg.mediaId!] = contact;
}
}
}
for (final mediaFile in mediaFiles) { for (final mediaFile in mediaFiles) {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue; if (!mediaService.imagePreviewAvailable) continue;
if (!mediaService.mediaFile.hasThumbnail && final senderContact = mediaIdToSender[mediaFile.mediaId];
mediaService.mediaFile.type != MediaType.audio) {
unawaited(mediaService.createThumbnail());
}
final senderContact = mediaIdToSenderContact[mediaFile.mediaId];
final item = MemoryItem( final item = MemoryItem(
mediaService: mediaService, mediaService: mediaService,
messages: [], messages: [],
@ -282,7 +157,6 @@ class MemoriesService {
final tempMonths = <String>[]; final tempMonths = <String>[];
var lastMonth = ''; var lastMonth = '';
// High performance grouping leveraging pre-computed createdAtMonth column
for (var i = 0; i < tempGalleryItems.length; i++) { for (var i = 0; i < tempGalleryItems.length; i++) {
final mFile = tempGalleryItems[i].mediaService.mediaFile; final mFile = tempGalleryItems[i].mediaService.mediaFile;
final month = final month =
@ -306,19 +180,101 @@ class MemoriesService {
final sortedGalleryItemsLastYears = final sortedGalleryItemsLastYears =
SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears); SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
final newState = MemoriesState( return MemoriesState(
filesToMigrate: _currentState.filesToMigrate, filesToMigrate: filesToMigrate,
galleryItems: tempGalleryItems, galleryItems: tempGalleryItems,
months: tempMonths, months: tempMonths,
orderedByMonth: tempOrderedByMonth, orderedByMonth: tempOrderedByMonth,
galleryItemsLastYears: sortedGalleryItemsLastYears, galleryItemsLastYears: sortedGalleryItemsLastYears,
); );
}
Future<void> _initAsync() async {
try {
final pendingFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingMigration();
if (pendingFiles.isNotEmpty) {
_updateMigrationCount(pendingFiles.length);
for (final mediaFile in pendingFiles) {
final mediaService = MediaFileService(mediaFile);
if (mediaService.mediaFile.storedFileHash == null) {
await mediaService.hashMediaFile();
}
if (!mediaService.mediaFile.hasCropAnalyzed) {
await mediaService.cropTransparentBorders();
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
}
if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync()) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
} else if (mediaFile.type != MediaType.audio) {
await mediaService.createThumbnail();
}
}
_updateMigrationCount(_currentState.filesToMigrate - 1);
}
_updateMigrationCount(0);
}
await _dbSubscription?.cancel();
_dbSubscription = twonlyDB.mediaFilesDao
.watchAllStoredMediaFiles()
.listen(_processMediaFilesStream);
} catch (e) {
Log.error('Error initializing MemoriesService: $e');
}
}
Future<void> _processMediaFilesStream(List<MediaFile> mediaFiles) async {
try {
final mediaIds = mediaFiles.map((m) => m.mediaId).toList();
final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds(
mediaIds,
);
final allContacts = await twonlyDB.contactsDao.getAllContacts();
final contactMap = {for (final c in allContacts) c.userId: c};
final mediaIdToSenderContact = <String, Contact>{};
for (final msg in allMessages) {
if (msg.mediaId != null && msg.senderId != null) {
final contact = contactMap[msg.senderId];
if (contact != null) {
mediaIdToSenderContact[msg.mediaId!] = contact;
}
}
}
final newState = _computeState(
mediaFiles: mediaFiles,
mediaIdToSender: mediaIdToSenderContact,
filesToMigrate: _currentState.filesToMigrate,
);
for (final item in newState.galleryItems) {
if (!item.mediaService.mediaFile.hasThumbnail &&
item.mediaService.mediaFile.type != MediaType.audio) {
unawaited(item.mediaService.createThumbnail());
}
}
_cachedState = newState; _cachedState = newState;
_updateStateWithObject(newState); _updateState(newState);
// Persist to KeyValueStore cache asynchronously // Persist to KeyValueStore cache asynchronously
final cacheList = tempGalleryItems final cacheList = newState.galleryItems
.map( .map(
(item) => { (item) => {
'mediaId': item.mediaService.mediaFile.mediaId, 'mediaId': item.mediaService.mediaFile.mediaId,
@ -332,15 +288,17 @@ class MemoriesService {
} }
} }
void _updateStateWithObject(MemoriesState newState) { void _updateState(MemoriesState newState) {
_currentState = newState; _currentState = newState;
if (!_stateController.isClosed) { _notifyState();
_stateController.add(_currentState);
}
} }
void _updateState({int? filesToMigrate}) { void _updateMigrationCount(int filesToMigrate) {
_currentState = _currentState.copyWith(filesToMigrate: filesToMigrate); _currentState = _currentState.copyWith(filesToMigrate: filesToMigrate);
_notifyState();
}
void _notifyState() {
if (!_stateController.isClosed) { if (!_stateController.isClosed) {
_stateController.add(_currentState); _stateController.add(_currentState);
} }

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.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/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -64,6 +65,22 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
defaultAutoDownloadOptions; defaultAutoDownloadOptions;
return ListView( return ListView(
children: [ children: [
FutureBuilder<Map<MediaType, int>>(
future: twonlyDB.mediaFilesDao.getStorageStats(),
builder: (context, snapshot) {
final stats = snapshot.data ?? {};
final totalBytes = stats.values.fold<int>(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( ListTile(
title: Text(context.lang.settingsStorageDataStoreInGTitle), title: Text(context.lang.settingsStorageDataStoreInGTitle),
subtitle: Text( subtitle: Text(

View file

@ -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<ManageStorageView> createState() => _ManageStorageViewState();
}
class _ManageStorageViewState extends State<ManageStorageView> {
Map<MediaType, int> _stats = {};
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _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<int>(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,
),
),
],
),
);
}
}