improved performance and battery usage on older devices

This commit is contained in:
2026-09-25 23:58:08 +02:00
parent ed8e52f475
commit 56a497985b
70 changed files with 1631 additions and 621 deletions
+1 -1
View File
@@ -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',
+24 -9
View File
@@ -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,
+14 -4
View File
@@ -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,
];
}