improved performance and battery usage on older devices
This commit is contained in:
@@ -21,7 +21,7 @@ class SortOptions {
|
||||
SortOption.name: BetterSortOption(
|
||||
displayName: 'Name',
|
||||
icon: Icons.sort_by_alpha_outlined,
|
||||
compare: (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
compare: (a, b) => a.lowerName.compareTo(b.lowerName),
|
||||
),
|
||||
SortOption.date: BetterSortOption(
|
||||
displayName: 'Datum',
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
|
||||
import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
@@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> {
|
||||
late bool currentSortDirection;
|
||||
late final StreamSubscription<String> _invalidationSub;
|
||||
|
||||
// The list builder also runs for loading flips and parent rebuilds; only a
|
||||
// new listing or a changed sort needs another sort pass.
|
||||
Object? _sortedKey;
|
||||
List<CacheableFile> _sortedFiles = const [];
|
||||
|
||||
List<CacheableFile> _sorted(ListFilesResponse listing, bool foldersToTop) {
|
||||
final key = (listing, currentSort, currentSortDirection, foldersToTop);
|
||||
if (key == _sortedKey) return _sortedFiles;
|
||||
_sortedKey = key;
|
||||
return _sortedFiles = listing.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: foldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
}
|
||||
|
||||
// Cache key in FilesBloc's pathString format: '/' for root, otherwise
|
||||
// segments joined without leading/trailing slash.
|
||||
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
|
||||
@@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<FilesBloc>();
|
||||
// Selected here, not in the LoadableStateConsumer child: that closure runs
|
||||
// during the consumer's build, where this context may not subscribe.
|
||||
final foldersToTop = context.select(
|
||||
(SettingsCubit c) => c.state.fileSettings.sortFoldersToTop,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||
@@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
text: 'Der Ordner ist leer',
|
||||
);
|
||||
}
|
||||
final files = listing.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.fileSettings
|
||||
.sortFoldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
final files = _sorted(listing, foldersToTop);
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
|
||||
@@ -191,18 +191,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
final HttpClientResponse uploadTask;
|
||||
final fileIndex = _uploadableFiles.indexOf(file);
|
||||
var lastPercent = -1;
|
||||
try {
|
||||
uploadTask = await webdavClient.putFile(
|
||||
File(filePath),
|
||||
fileStat,
|
||||
PathUri.parse(fullRemotePath),
|
||||
onProgress: (progress) {
|
||||
// Called per 64 KB chunk — thousands of times for a video. Only
|
||||
// rebuild when the visible percentage actually moves.
|
||||
final percent = (progress * 100).floor();
|
||||
if (!mounted || percent == lastPercent) return;
|
||||
lastPercent = percent;
|
||||
setState(() {
|
||||
file._uploadProgress = progress;
|
||||
_overallProgressValue =
|
||||
((progress + _uploadableFiles.indexOf(file)) /
|
||||
_uploadableFiles.length)
|
||||
.toDouble();
|
||||
((progress + fileIndex) / _uploadableFiles.length).toDouble();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -246,7 +251,12 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
itemCount: _uploadableFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentFile = _uploadableFiles[index];
|
||||
currentFile.fileNameController.text = currentFile.fileName;
|
||||
// Only sync when it differs: assigning text resets selection
|
||||
// and notifies the field on every (progress) rebuild.
|
||||
if (currentFile.fileNameController.text !=
|
||||
currentFile.fileName) {
|
||||
currentFile.fileNameController.text = currentFile.fileName;
|
||||
}
|
||||
return ListTile(
|
||||
title: TextField(
|
||||
readOnly: _isUploading,
|
||||
|
||||
@@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier {
|
||||
Object? _serverError;
|
||||
int _serverEpoch = 0;
|
||||
bool _disposed = false;
|
||||
Future<List<CacheableFile>>? _localIndex;
|
||||
|
||||
Future<List<CacheableFile>> _searchLocal(List<String> pathScope) async =>
|
||||
searchLocalCacheIndex(
|
||||
await (_localIndex ??= loadLocalCacheIndex()),
|
||||
_query,
|
||||
pathScope: pathScope,
|
||||
);
|
||||
|
||||
/// Guards against the race where the search delegate is closed (and the
|
||||
/// controller disposed) while a debounced cache scan or server call is
|
||||
@@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
|
||||
final cacheHits = await _searchLocal(_pathScope);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
@@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await searchLocalCaches(_query);
|
||||
final cacheHits = await _searchLocal(const []);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
|
||||
@@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget {
|
||||
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
|
||||
final groups = _groupByParent(combined);
|
||||
final orderedKeys = groups.keys.toList()..sort();
|
||||
final items = <Widget>[];
|
||||
for (final folder in orderedKeys) {
|
||||
final segments = _segmentsOf(folder);
|
||||
items.add(
|
||||
_FolderHeader(
|
||||
folder: folder,
|
||||
onOpen: () {
|
||||
onResultTap?.call();
|
||||
AppRoutes.openFolder(context, segments);
|
||||
},
|
||||
),
|
||||
);
|
||||
for (final file in groups[folder]!) {
|
||||
items.add(
|
||||
FileElement(
|
||||
file,
|
||||
segments,
|
||||
controller.retry,
|
||||
highlight: controller.query,
|
||||
),
|
||||
// Flat (folder header | file) rows built lazily: results can run into
|
||||
// the hundreds and arrive in several batches per query.
|
||||
final rows = <(String, CacheableFile?)>[
|
||||
for (final folder in orderedKeys) ...[
|
||||
(folder, null),
|
||||
for (final file in groups[folder]!) (folder, file),
|
||||
],
|
||||
];
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final (folder, file) = rows[index];
|
||||
final segments = _segmentsOf(folder);
|
||||
if (file == null) {
|
||||
return _FolderHeader(
|
||||
key: ValueKey('folder:$folder'),
|
||||
folder: folder,
|
||||
onOpen: () {
|
||||
onResultTap?.call();
|
||||
AppRoutes.openFolder(context, segments);
|
||||
},
|
||||
);
|
||||
}
|
||||
return FileElement(
|
||||
file,
|
||||
segments,
|
||||
controller.retry,
|
||||
key: ValueKey('file:${file.path}'),
|
||||
highlight: controller.query,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(padding: EdgeInsets.zero, children: items);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
|
||||
|
||||
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
|
||||
final map = <String, List<CacheableFile>>{};
|
||||
for (final file in files) {
|
||||
@@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _parentOf(CacheableFile file) {
|
||||
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final stripped = file.path.replaceAll(_edgeSlashes, '');
|
||||
final segments = stripped.split('/');
|
||||
if (segments.length <= 1) return '/';
|
||||
segments.removeLast();
|
||||
@@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
}
|
||||
|
||||
List<String> _segmentsOf(String folder) {
|
||||
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final stripped = folder.replaceAll(_edgeSlashes, '');
|
||||
if (stripped.isEmpty) return const [];
|
||||
return stripped.split('/');
|
||||
}
|
||||
@@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget {
|
||||
class _FolderHeader extends StatelessWidget {
|
||||
final String folder;
|
||||
final VoidCallback onOpen;
|
||||
const _FolderHeader({required this.folder, required this.onOpen});
|
||||
const _FolderHeader({
|
||||
required this.folder,
|
||||
required this.onOpen,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:localstore/localstore.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../api/cache_store.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../../api/request_cache.dart';
|
||||
|
||||
/// Document key prefix used by `ListFilesCache._documentId`.
|
||||
const String _folderCachePrefix = 'wd-folder-';
|
||||
|
||||
/// Scans every cached folder listing in Localstore and returns files/folders
|
||||
/// whose name contains [query] (case-insensitive).
|
||||
///
|
||||
/// [pathScope] restricts results to entries whose WebDAV path starts with
|
||||
/// the given folder. Pass an empty list (or null) to search globally.
|
||||
///
|
||||
/// [docs] is an injection seam for tests — production callers leave it null
|
||||
/// so the helper reads from the real Localstore.
|
||||
Future<List<CacheableFile>> searchLocalCaches(
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
Map<String, dynamic>? docs,
|
||||
}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
|
||||
final raw =
|
||||
docs ??
|
||||
await Localstore.instance.collection(RequestCache.collection).get();
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
|
||||
final results = <String, CacheableFile>{};
|
||||
for (final entry in raw.entries) {
|
||||
final docKey = entry.key.split('/').last;
|
||||
if (!docKey.startsWith(_folderCachePrefix)) continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value is! Map) continue;
|
||||
final json = value['json'];
|
||||
if (json is! String) continue;
|
||||
/// Every file and folder from the cached folder listings, deduplicated by
|
||||
/// path. Built once per search session: reading and parsing all listings on
|
||||
/// each keystroke used to stall typing.
|
||||
Future<List<CacheableFile>> loadLocalCacheIndex() async {
|
||||
final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix);
|
||||
if (entries.isEmpty) return const [];
|
||||
final payloads = [for (final entry in entries.values) entry.json];
|
||||
return compute(buildLocalCacheIndex, payloads);
|
||||
}
|
||||
|
||||
/// Parses cached `ListFilesResponse` payloads into a deduplicated file list.
|
||||
/// Unparsable payloads are skipped.
|
||||
List<CacheableFile> buildLocalCacheIndex(List<String> payloads) {
|
||||
final byPath = <String, CacheableFile>{};
|
||||
for (final json in payloads) {
|
||||
final ListFilesResponse listing;
|
||||
try {
|
||||
listing = ListFilesResponse.fromJson(
|
||||
@@ -52,14 +32,32 @@ Future<List<CacheableFile>> searchLocalCaches(
|
||||
} on Object {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final file in listing.files) {
|
||||
if (!file.name.toLowerCase().contains(needle)) continue;
|
||||
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
|
||||
continue;
|
||||
}
|
||||
results[file.path] ??= file;
|
||||
byPath[file.path] ??= file;
|
||||
}
|
||||
}
|
||||
return results.values.toList();
|
||||
return byPath.values.toList();
|
||||
}
|
||||
|
||||
/// Files in [index] whose name contains [query] (case-insensitive).
|
||||
///
|
||||
/// [pathScope] restricts results to entries whose WebDAV path starts with
|
||||
/// the given folder. Pass an empty list (or null) to search globally.
|
||||
List<CacheableFile> searchLocalCacheIndex(
|
||||
List<CacheableFile> index,
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
}) {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
return [
|
||||
for (final file in index)
|
||||
if (file.lowerName.contains(needle) &&
|
||||
(scopePrefix.isEmpty || file.path.startsWith(scopePrefix)))
|
||||
file,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user