mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 07:22:13 +00:00
New: Manage storage view
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
5556532879
commit
e9b550023f
11 changed files with 355 additions and 145 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue