improved start-up loading time
This commit is contained in:
parent
fc10f97bfa
commit
7aee4c6db3
10 changed files with 50 additions and 62 deletions
|
@ -3,30 +3,6 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class DateMetadata {
|
|
||||||
final int? contentId, dateMillis;
|
|
||||||
|
|
||||||
DateMetadata({
|
|
||||||
this.contentId,
|
|
||||||
this.dateMillis,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory DateMetadata.fromMap(Map map) {
|
|
||||||
return DateMetadata(
|
|
||||||
contentId: map['contentId'],
|
|
||||||
dateMillis: map['dateMillis'] ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
|
||||||
'contentId': contentId,
|
|
||||||
'dateMillis': dateMillis,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}';
|
|
||||||
}
|
|
||||||
|
|
||||||
class CatalogMetadata {
|
class CatalogMetadata {
|
||||||
final int? contentId, dateMillis;
|
final int? contentId, dateMillis;
|
||||||
final bool isAnimated, isGeotiff, is360, isMultiPage;
|
final bool isAnimated, isGeotiff, is360, isMultiPage;
|
||||||
|
|
|
@ -36,7 +36,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearDates();
|
Future<void> clearDates();
|
||||||
|
|
||||||
Future<List<DateMetadata>> loadDates();
|
Future<Map<int?, int?>> loadDates();
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata
|
||||||
|
|
||||||
|
@ -260,12 +260,10 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<DateMetadata>> loadDates() async {
|
Future<Map<int?, int?>> loadDates() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(dateTakenTable);
|
final maps = await db.query(dateTakenTable);
|
||||||
final metadataEntries = maps.map((map) => DateMetadata.fromMap(map)).toList();
|
final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int)));
|
||||||
// debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
|
||||||
return metadataEntries;
|
return metadataEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -280,11 +278,9 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(metadataTable);
|
final maps = await db.query(metadataTable);
|
||||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
||||||
// debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
|
||||||
return metadataEntries;
|
return metadataEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -318,7 +314,10 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
if (metadata.dateMillis != 0) {
|
if (metadata.dateMillis != 0) {
|
||||||
batch.insert(
|
batch.insert(
|
||||||
dateTakenTable,
|
dateTakenTable,
|
||||||
DateMetadata(contentId: metadata.contentId, dateMillis: metadata.dateMillis).toMap(),
|
{
|
||||||
|
'contentId': metadata.contentId,
|
||||||
|
'dateMillis': metadata.dateMillis,
|
||||||
|
},
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -340,11 +339,9 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<AddressDetails>> loadAddresses() async {
|
Future<List<AddressDetails>> loadAddresses() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(addressTable);
|
final maps = await db.query(addressTable);
|
||||||
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
||||||
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
|
|
||||||
return addresses;
|
return addresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
|
@ -22,6 +21,8 @@ import 'package:flutter/foundation.dart';
|
||||||
mixin SourceBase {
|
mixin SourceBase {
|
||||||
EventBus get eventBus;
|
EventBus get eventBus;
|
||||||
|
|
||||||
|
Map<int?, AvesEntry> get entryById;
|
||||||
|
|
||||||
Set<AvesEntry> get visibleEntries;
|
Set<AvesEntry> get visibleEntries;
|
||||||
|
|
||||||
List<AvesEntry> get sortedEntriesByDate;
|
List<AvesEntry> get sortedEntriesByDate;
|
||||||
|
@ -41,6 +42,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
@override
|
@override
|
||||||
EventBus get eventBus => _eventBus;
|
EventBus get eventBus => _eventBus;
|
||||||
|
|
||||||
|
final Map<int?, AvesEntry> _entryById = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById);
|
||||||
|
|
||||||
final Set<AvesEntry> _rawEntries = {};
|
final Set<AvesEntry> _rawEntries = {};
|
||||||
|
|
||||||
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
|
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
|
||||||
|
@ -61,11 +67,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
return _sortedEntriesByDate!;
|
return _sortedEntriesByDate!;
|
||||||
}
|
}
|
||||||
|
|
||||||
late List<DateMetadata> _savedDates;
|
late Map<int?, int?> _savedDates;
|
||||||
|
|
||||||
Future<void> loadDates() async {
|
Future<void> loadDates() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
_savedDates = List.unmodifiable(await metadataDb.loadDates());
|
_savedDates = Map.unmodifiable(await metadataDb.loadDates());
|
||||||
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
|
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,14 +90,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
|
|
||||||
void addEntries(Set<AvesEntry> entries) {
|
void addEntries(Set<AvesEntry> entries) {
|
||||||
if (entries.isEmpty) return;
|
if (entries.isEmpty) return;
|
||||||
|
|
||||||
|
final newIdMapEntries = Map.fromEntries(entries.map((v) => MapEntry(v.contentId, v)));
|
||||||
if (_rawEntries.isNotEmpty) {
|
if (_rawEntries.isNotEmpty) {
|
||||||
final newContentIds = entries.map((entry) => entry.contentId).toSet();
|
final newContentIds = newIdMapEntries.keys.toSet();
|
||||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||||
}
|
}
|
||||||
entries.forEach((entry) {
|
|
||||||
final contentId = entry.contentId;
|
entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]);
|
||||||
entry.catalogDateMillis = _savedDates.firstWhereOrNull((metadata) => metadata.contentId == contentId)?.dateMillis;
|
|
||||||
});
|
_entryById.addAll(newIdMapEntries);
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
_invalidate(entries);
|
_invalidate(entries);
|
||||||
|
|
||||||
|
@ -104,6 +112,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||||
await favourites.remove(entries);
|
await favourites.remove(entries);
|
||||||
await covers.removeEntries(entries);
|
await covers.removeEntries(entries);
|
||||||
|
|
||||||
|
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||||
_rawEntries.removeAll(entries);
|
_rawEntries.removeAll(entries);
|
||||||
_invalidate(entries);
|
_invalidate(entries);
|
||||||
|
|
||||||
|
@ -114,6 +124,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearEntries() {
|
void clearEntries() {
|
||||||
|
_entryById.clear();
|
||||||
_rawEntries.clear();
|
_rawEntries.clear();
|
||||||
_invalidate();
|
_invalidate();
|
||||||
|
|
||||||
|
|
|
@ -20,10 +20,8 @@ mixin LocationMixin on SourceBase {
|
||||||
Future<void> loadAddresses() async {
|
Future<void> loadAddresses() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final saved = await metadataDb.loadAddresses();
|
final saved = await metadataDb.loadAddresses();
|
||||||
visibleEntries.forEach((entry) {
|
final idMap = entryById;
|
||||||
final contentId = entry.contentId;
|
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||||
entry.addressDetails = saved.firstWhereOrNull((address) => address.contentId == contentId);
|
|
||||||
});
|
|
||||||
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||||
onAddressMetadataChanged();
|
onAddressMetadataChanged();
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
settings.catalogTimeZone = currentTimeZone;
|
settings.catalogTimeZone = currentTimeZone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await loadDates(); // 100ms for 5400 entries
|
await loadDates();
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||||
}
|
}
|
||||||
|
@ -49,15 +49,15 @@ class MediaStoreSource extends CollectionSource {
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
clearEntries();
|
clearEntries();
|
||||||
|
|
||||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
final oldEntries = await metadataDb.loadEntries();
|
||||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||||
|
|
||||||
// show known entries
|
// show known entries
|
||||||
addEntries(oldEntries);
|
addEntries(oldEntries);
|
||||||
await loadCatalogMetadata(); // 600ms for 5500 entries
|
await loadCatalogMetadata();
|
||||||
await loadAddresses(); // 200ms for 3000 entries
|
await loadAddresses();
|
||||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
|
@ -94,7 +94,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
addPendingEntries();
|
addPendingEntries();
|
||||||
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
|
||||||
await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries
|
await metadataDb.saveEntries(allNewEntries);
|
||||||
|
|
||||||
if (allNewEntries.isNotEmpty) {
|
if (allNewEntries.isNotEmpty) {
|
||||||
// new entries include existing entries with obsolete paths
|
// new entries include existing entries with obsolete paths
|
||||||
|
|
|
@ -15,10 +15,8 @@ mixin TagMixin on SourceBase {
|
||||||
Future<void> loadCatalogMetadata() async {
|
Future<void> loadCatalogMetadata() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final saved = await metadataDb.loadMetadataEntries();
|
final saved = await metadataDb.loadMetadataEntries();
|
||||||
visibleEntries.forEach((entry) {
|
final idMap = entryById;
|
||||||
final contentId = entry.contentId;
|
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||||
entry.catalogMetadata = saved.firstWhereOrNull((metadata) => metadata.contentId == contentId);
|
|
||||||
});
|
|
||||||
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||||
onCatalogMetadataChanged();
|
onCatalogMetadataChanged();
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,6 +95,14 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
||||||
},
|
},
|
||||||
title: const Text('Show tasks overlay'),
|
title: const Text('Show tasks overlay'),
|
||||||
),
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final source = context.read<CollectionSource>();
|
||||||
|
await source.init();
|
||||||
|
await source.refresh();
|
||||||
|
},
|
||||||
|
child: const Text('Source full refresh'),
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
|
|
@ -17,7 +17,7 @@ class DebugAppDatabaseSection extends StatefulWidget {
|
||||||
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
|
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<int> _dbFileSizeLoader;
|
late Future<int> _dbFileSizeLoader;
|
||||||
late Future<Set<AvesEntry>> _dbEntryLoader;
|
late Future<Set<AvesEntry>> _dbEntryLoader;
|
||||||
late Future<List<DateMetadata>> _dbDateLoader;
|
late Future<Map<int?, int?>> _dbDateLoader;
|
||||||
late Future<List<CatalogMetadata>> _dbMetadataLoader;
|
late Future<List<CatalogMetadata>> _dbMetadataLoader;
|
||||||
late Future<List<AddressDetails>> _dbAddressLoader;
|
late Future<List<AddressDetails>> _dbAddressLoader;
|
||||||
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||||
|
@ -82,7 +82,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
FutureBuilder<List>(
|
FutureBuilder<Map<int?, int?>>(
|
||||||
future: _dbDateLoader,
|
future: _dbDateLoader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||||
|
|
|
@ -18,7 +18,7 @@ class DbTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DbTabState extends State<DbTab> {
|
class _DbTabState extends State<DbTab> {
|
||||||
late Future<DateMetadata?> _dbDateLoader;
|
late Future<int?> _dbDateLoader;
|
||||||
late Future<AvesEntry?> _dbEntryLoader;
|
late Future<AvesEntry?> _dbEntryLoader;
|
||||||
late Future<CatalogMetadata?> _dbMetadataLoader;
|
late Future<CatalogMetadata?> _dbMetadataLoader;
|
||||||
late Future<AddressDetails?> _dbAddressLoader;
|
late Future<AddressDetails?> _dbAddressLoader;
|
||||||
|
@ -33,7 +33,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
|
|
||||||
void _loadDatabase() {
|
void _loadDatabase() {
|
||||||
final contentId = entry.contentId;
|
final contentId = entry.contentId;
|
||||||
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]);
|
||||||
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
|
@ -45,7 +45,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<DateMetadata?>(
|
FutureBuilder<int?>(
|
||||||
future: _dbDateLoader,
|
future: _dbDateLoader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||||
|
@ -58,7 +58,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
if (data != null)
|
if (data != null)
|
||||||
InfoRowGroup(
|
InfoRowGroup(
|
||||||
info: {
|
info: {
|
||||||
'dateMillis': '${data.dateMillis}',
|
'dateMillis': '$data',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -24,7 +24,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<DateMetadata>> loadDates() => SynchronousFuture([]);
|
Future<Map<int?, int?>> loadDates() => SynchronousFuture({});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
|
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
|
||||||
|
|
Loading…
Reference in a new issue