simplified code across the app and consolidated duplicated helpers
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'sort_options.dart';
|
||||
|
||||
typedef FileSort = ({SortOption by, bool ascending, bool foldersToTop});
|
||||
|
||||
/// Selector for `context.select`, so folder views rebuild only when the
|
||||
/// file sort settings change.
|
||||
FileSort fileSortOf(SettingsCubit cubit) {
|
||||
final settings = cubit.state.fileSettings;
|
||||
return (
|
||||
by: settings.sortBy,
|
||||
ascending: settings.ascending,
|
||||
foldersToTop: settings.sortFoldersToTop,
|
||||
);
|
||||
}
|
||||
|
||||
extension FileSortListing on ListFilesResponse {
|
||||
List<CacheableFile> sortedBy(FileSort sort) => sortBy(
|
||||
sortOption: sort.by,
|
||||
foldersToTop: sort.foldersToTop,
|
||||
reversed: sort.ascending,
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,7 @@ IconData iconForFile(CacheableFile file) {
|
||||
final byMime = _iconForMime(mime);
|
||||
if (byMime != null) return byMime;
|
||||
}
|
||||
final ext = _extensionOf(file.name);
|
||||
if (ext != null) {
|
||||
final byExt = _extensionIcons[ext];
|
||||
if (byExt != null) return byExt;
|
||||
}
|
||||
return Icons.insert_drive_file_outlined;
|
||||
return iconForFileName(file.name);
|
||||
}
|
||||
|
||||
/// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Paths relative to the WebDAV root, as used by `CacheableFile.path`: no
|
||||
// leading slash, folders end with '/', the root folder is ''.
|
||||
|
||||
final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
|
||||
|
||||
List<String> webdavSegmentsOf(String path) {
|
||||
final stripped = path.replaceAll(_edgeSlashes, '');
|
||||
return stripped.isEmpty ? [] : stripped.split('/');
|
||||
}
|
||||
|
||||
List<String> webdavParentSegmentsOf(String path) {
|
||||
final segments = webdavSegmentsOf(path);
|
||||
if (segments.isNotEmpty) segments.removeLast();
|
||||
return segments;
|
||||
}
|
||||
|
||||
String webdavFolderPath(List<String> segments) =>
|
||||
segments.isEmpty ? '' : '${segments.join('/')}/';
|
||||
|
||||
String webdavJoin(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
/// Folder key used by `ListFilesCache` and `FilesBloc`: no leading or
|
||||
/// trailing slash, root is '/'.
|
||||
String listFilesCacheKey(List<String> segments) =>
|
||||
segments.isEmpty ? '/' : segments.join('/');
|
||||
@@ -11,10 +11,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../utils/cache_invalidation_bus.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'data/sort_options.dart';
|
||||
import 'data/file_sort.dart';
|
||||
import 'data/webdav_path.dart';
|
||||
import 'files_upload_dialog.dart';
|
||||
import 'search/files_search_delegate.dart';
|
||||
import 'widgets/add_file_menu.dart';
|
||||
@@ -44,9 +44,6 @@ class _FilesView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FilesViewState extends State<_FilesView> {
|
||||
late final SettingsCubit settings;
|
||||
late SortOption currentSort;
|
||||
late bool currentSortDirection;
|
||||
late final StreamSubscription<String> _invalidationSub;
|
||||
|
||||
// The list builder also runs for loading flips and parent rebuilds; only a
|
||||
@@ -54,32 +51,16 @@ class _FilesViewState extends State<_FilesView> {
|
||||
Object? _sortedKey;
|
||||
List<CacheableFile> _sortedFiles = const [];
|
||||
|
||||
List<CacheableFile> _sorted(ListFilesResponse listing, bool foldersToTop) {
|
||||
final key = (listing, currentSort, currentSortDirection, foldersToTop);
|
||||
List<CacheableFile> _sorted(ListFilesResponse listing, FileSort sort) {
|
||||
final key = (listing, sort);
|
||||
if (key == _sortedKey) return _sortedFiles;
|
||||
_sortedKey = key;
|
||||
return _sortedFiles = listing.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: foldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
return _sortedFiles = listing.sortedBy(sort);
|
||||
}
|
||||
|
||||
// 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('/');
|
||||
|
||||
// Relative folder path matching the WebDAV format used by `CacheableFile.path`
|
||||
// (no leading slash; trailing slash for non-root). Empty string means root.
|
||||
String get _currentFolderPath =>
|
||||
widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
settings = context.read<SettingsCubit>();
|
||||
currentSort = settings.val().fileSettings.sortBy;
|
||||
currentSortDirection = settings.val().fileSettings.ascending;
|
||||
_invalidationSub = CacheInvalidationBus.listFilesStream.listen(
|
||||
_onInvalidation,
|
||||
);
|
||||
@@ -87,7 +68,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
|
||||
void _onInvalidation(String invalidatedPath) {
|
||||
if (!mounted) return;
|
||||
if (invalidatedPath != _myPathString) return;
|
||||
if (invalidatedPath != listFilesCacheKey(widget.path)) return;
|
||||
context.read<FilesBloc>().refresh();
|
||||
}
|
||||
|
||||
@@ -118,29 +99,12 @@ class _FilesViewState extends State<_FilesView> {
|
||||
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,
|
||||
);
|
||||
final sort = context.select(fileSortOf);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||
actions: [
|
||||
FilesSortActions(
|
||||
currentSort: currentSort,
|
||||
ascending: currentSortDirection,
|
||||
onDirectionChanged: (e) {
|
||||
setState(() {
|
||||
currentSortDirection = e;
|
||||
settings.val(write: true).fileSettings.ascending = e;
|
||||
});
|
||||
},
|
||||
onSortChanged: (e) {
|
||||
setState(() {
|
||||
currentSort = e;
|
||||
settings.val(write: true).fileSettings.sortBy = e;
|
||||
});
|
||||
},
|
||||
),
|
||||
const FilesSortActions(),
|
||||
IconButton(
|
||||
tooltip: 'Suchen',
|
||||
icon: const Icon(Icons.search),
|
||||
@@ -162,7 +126,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
body: Column(
|
||||
children: [
|
||||
ClipboardBanner(
|
||||
currentFolder: _currentFolderPath,
|
||||
currentFolder: webdavFolderPath(widget.path),
|
||||
onPasteDone: bloc.refresh,
|
||||
),
|
||||
Expanded(
|
||||
@@ -176,7 +140,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
text: 'Der Ordner ist leer',
|
||||
);
|
||||
}
|
||||
final files = _sorted(listing, foldersToTop);
|
||||
final files = _sorted(listing, sort);
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
|
||||
@@ -28,13 +28,15 @@ class FilesUploadDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class UploadableFile {
|
||||
TextEditingController fileNameController = TextEditingController();
|
||||
String filePath;
|
||||
String fileName;
|
||||
final String filePath;
|
||||
final TextEditingController fileNameController;
|
||||
double? _uploadProgress;
|
||||
bool isConflicting = false;
|
||||
|
||||
UploadableFile(this.filePath, this.fileName);
|
||||
UploadableFile(this.filePath, String fileName)
|
||||
: fileNameController = TextEditingController(text: fileName);
|
||||
|
||||
String get fileName => fileNameController.text;
|
||||
}
|
||||
|
||||
class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
@@ -47,10 +49,22 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_uploadableFiles = widget.filePaths.map((filePath) {
|
||||
var fileName = filePath.split(Platform.pathSeparator).last;
|
||||
return UploadableFile(filePath, fileName);
|
||||
}).toList();
|
||||
_uploadableFiles = widget.filePaths
|
||||
.map(
|
||||
(filePath) => UploadableFile(
|
||||
filePath,
|
||||
filePath.split(Platform.pathSeparator).last,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final file in _uploadableFiles) {
|
||||
file.fileNameController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetProgress() {
|
||||
@@ -69,6 +83,10 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
void _clearConflict(UploadableFile file) {
|
||||
if (file.isConflicting) setState(() => file.isConflicting = false);
|
||||
}
|
||||
|
||||
Future<void> uploadFiles({bool override = false}) async {
|
||||
setState(() {
|
||||
_isUploading = true;
|
||||
@@ -82,36 +100,39 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (!override) {
|
||||
List<dynamic> result;
|
||||
final List<String> existingPaths;
|
||||
try {
|
||||
result = (await webdavClient.propfind(
|
||||
final result = await webdavClient.propfind(
|
||||
PathUri.parse(widget.remotePath),
|
||||
)).responses;
|
||||
);
|
||||
existingPaths = [
|
||||
for (final response in result.responses)
|
||||
Uri.decodeComponent(response.href!),
|
||||
];
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showUploadError('Verbindung fehlgeschlagen: $e');
|
||||
return;
|
||||
}
|
||||
final conflictingFiles = _uploadableFiles.where((file) {
|
||||
final fileName = file.fileName;
|
||||
return result.any(
|
||||
(element) => Uri.decodeComponent(
|
||||
(element as WebDavResponse).href!,
|
||||
).endsWith('/$fileName'),
|
||||
);
|
||||
}).toList();
|
||||
final conflictingFiles = _uploadableFiles
|
||||
.where(
|
||||
(file) => existingPaths.any(
|
||||
(path) => path.endsWith('/${file.fileName}'),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (conflictingFiles.isNotEmpty) {
|
||||
if (!mounted) return;
|
||||
final replaceFiles = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(10),
|
||||
title: const Text('Konflikt', textAlign: TextAlign.center),
|
||||
content: conflictingFiles.length == 1
|
||||
? Text(
|
||||
'Eine Datei mit dem Namen "${conflictingFiles.map((e) => e.fileName).first}" existiert bereits.',
|
||||
'Eine Datei mit dem Namen "${conflictingFiles.first.fileName}" existiert bereits.',
|
||||
textAlign: TextAlign.left,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
@@ -122,27 +143,20 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Bearbeiten', textAlign: TextAlign.center),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
title: 'Bestätigen?',
|
||||
content:
|
||||
'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?',
|
||||
onConfirm: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
confirmButton: 'Ja',
|
||||
cancelButton: 'Nein',
|
||||
),
|
||||
);
|
||||
},
|
||||
// ConfirmDialog closes itself first, so this pop closes the
|
||||
// conflict dialog.
|
||||
onPressed: () => ConfirmDialog(
|
||||
title: 'Bestätigen?',
|
||||
content:
|
||||
'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?',
|
||||
onConfirm: () => Navigator.pop(dialogContext, true),
|
||||
confirmButton: 'Ja',
|
||||
cancelButton: 'Nein',
|
||||
).asDialog(dialogContext),
|
||||
child: const Text('Überschreiben', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
@@ -163,9 +177,9 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
var uploadetFilePaths = <String>[];
|
||||
for (var file in _uploadableFiles) {
|
||||
for (final (fileIndex, file) in _uploadableFiles.indexed) {
|
||||
var fileName = file.fileName;
|
||||
var filePath = file.filePath;
|
||||
final filePath = file.filePath;
|
||||
|
||||
if (widget.uniqueNames) {
|
||||
final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
||||
@@ -177,7 +191,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
|
||||
setState(() {
|
||||
_infoText =
|
||||
'${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}';
|
||||
'${fileIndex + 1}/${_uploadableFiles.length}';
|
||||
});
|
||||
|
||||
// A vanished source file would otherwise surface as a cryptic
|
||||
@@ -193,7 +207,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
final HttpClientResponse uploadTask;
|
||||
final fileIndex = _uploadableFiles.indexOf(file);
|
||||
var lastPercent = -1;
|
||||
try {
|
||||
uploadTask = await webdavClient.putFile(
|
||||
@@ -253,12 +266,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
itemCount: _uploadableFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentFile = _uploadableFiles[index];
|
||||
// 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,
|
||||
@@ -273,24 +280,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
onChanged: (input) {
|
||||
currentFile.fileName = input;
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusBehaviour.textFieldTapOutside(context);
|
||||
if (currentFile.isConflicting) {
|
||||
setState(() {
|
||||
currentFile.isConflicting = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onEditingComplete: () {
|
||||
if (currentFile.isConflicting) {
|
||||
setState(() {
|
||||
currentFile.isConflicting = false;
|
||||
});
|
||||
}
|
||||
_clearConflict(currentFile);
|
||||
},
|
||||
onEditingComplete: () => _clearConflict(currentFile),
|
||||
),
|
||||
subtitle:
|
||||
_isUploading && (currentFile._uploadProgress ?? 0) < 1
|
||||
@@ -301,21 +295,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Container(
|
||||
trailing: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: EdgeInsets.zero,
|
||||
child: IconButton(
|
||||
tooltip: 'Datei entfernen',
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
if (!_isUploading) {
|
||||
if (_uploadableFiles.length - 1 <= 0) {
|
||||
if (_uploadableFiles.length == 1) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
setState(() {
|
||||
_uploadableFiles.removeAt(index);
|
||||
});
|
||||
final removed = _uploadableFiles[index];
|
||||
setState(() => _uploadableFiles.removeAt(index));
|
||||
// Its TextField is still mounted until the rebuild.
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => removed.fileNameController.dispose(),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
@@ -342,7 +338,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
const Spacer(),
|
||||
Visibility(
|
||||
visible: _isUploading,
|
||||
replacement: TextButton(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../../../../api/marianumcloud/search/search_files.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../utils/debouncer.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
import 'local_cache_search.dart';
|
||||
|
||||
/// Holds the live state of a Files-search session: current query, the latest
|
||||
@@ -28,12 +29,11 @@ class FilesSearchController extends ChangeNotifier {
|
||||
bool _disposed = false;
|
||||
Future<List<CacheableFile>>? _localIndex;
|
||||
|
||||
Future<List<CacheableFile>> _searchLocal(List<String> pathScope) async =>
|
||||
searchLocalCacheIndex(
|
||||
await (_localIndex ??= loadLocalCacheIndex()),
|
||||
_query,
|
||||
pathScope: pathScope,
|
||||
);
|
||||
Future<List<CacheableFile>> _searchLocal() 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
|
||||
@@ -45,10 +45,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
String get query => _query;
|
||||
List<String> get pathScope => List.unmodifiable(_pathScope);
|
||||
bool get isScoped => _pathScope.isNotEmpty;
|
||||
List<CacheableFile> get cacheResults => _cacheResults;
|
||||
List<CacheableFile> get serverResults => _serverResults;
|
||||
bool get serverLoading => _serverLoading;
|
||||
Object? get serverError => _serverError;
|
||||
|
||||
@@ -81,18 +78,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_safeNotify();
|
||||
return;
|
||||
}
|
||||
// Show loading immediately — even before the (typically fast) cache
|
||||
// scan resolves — so the indicator is visible the moment the user
|
||||
// starts typing rather than after the first await hop.
|
||||
_serverLoading = true;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await _searchLocal(_pathScope);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
_scheduleServerCall();
|
||||
await _searchLocalThenServer(epoch);
|
||||
}
|
||||
|
||||
/// Drops the path filter and re-runs the current search globally. Used by
|
||||
@@ -105,11 +91,18 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_safeNotify();
|
||||
return;
|
||||
}
|
||||
await _searchLocalThenServer(epoch);
|
||||
}
|
||||
|
||||
Future<void> _searchLocalThenServer(int epoch) async {
|
||||
// Show loading immediately — even before the (typically fast) cache
|
||||
// scan resolves — so the indicator is visible the moment the user
|
||||
// starts typing rather than after the first await hop.
|
||||
_serverLoading = true;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await _searchLocal(const []);
|
||||
final cacheHits = await _searchLocal();
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
@@ -135,7 +128,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
Future<void> _runServerCall() async {
|
||||
final epoch = _serverEpoch;
|
||||
final term = _query;
|
||||
final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/';
|
||||
final scopePrefix = webdavFolderPath(_pathScope);
|
||||
try {
|
||||
final response = await _api.run(term: term);
|
||||
if (epoch != _serverEpoch) return;
|
||||
|
||||
@@ -38,13 +38,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget buildResults(BuildContext context) {
|
||||
_controller.setQuery(query);
|
||||
return FilesSearchResults(
|
||||
controller: _controller,
|
||||
onResultTap: () => close(context, null),
|
||||
);
|
||||
}
|
||||
Widget buildResults(BuildContext context) => buildSuggestions(context);
|
||||
|
||||
@override
|
||||
Widget buildSuggestions(BuildContext context) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_
|
||||
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
|
||||
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../../widget/placeholder_view.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
import '../widgets/file_element.dart';
|
||||
import 'files_search_controller.dart';
|
||||
|
||||
@@ -80,8 +81,8 @@ class FilesSearchResults extends StatelessWidget {
|
||||
visible: showErrorScreen,
|
||||
message: errorMessage,
|
||||
),
|
||||
if (showEmpty) _emptyState(context),
|
||||
if (hasContent) _resultList(context, combined),
|
||||
if (showEmpty) _emptyState(),
|
||||
if (hasContent) _resultList(combined),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -89,7 +90,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyState(BuildContext context) => PlaceholderView(
|
||||
Widget _emptyState() => PlaceholderView(
|
||||
icon: Icons.search_off_outlined,
|
||||
text: 'Keine Treffer gefunden.',
|
||||
button: controller.isScoped
|
||||
@@ -101,7 +102,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
: null,
|
||||
);
|
||||
|
||||
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
|
||||
Widget _resultList(List<CacheableFile> combined) {
|
||||
final groups = _groupByParent(combined);
|
||||
final orderedKeys = groups.keys.toList()..sort();
|
||||
// Flat (folder header | file) rows built lazily: results can run into
|
||||
@@ -117,7 +118,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final (folder, file) = rows[index];
|
||||
final segments = _segmentsOf(folder);
|
||||
final segments = webdavSegmentsOf(folder);
|
||||
if (file == null) {
|
||||
return _FolderHeader(
|
||||
key: ValueKey('folder:$folder'),
|
||||
@@ -139,29 +140,14 @@ class FilesSearchResults extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
|
||||
|
||||
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
|
||||
final map = <String, List<CacheableFile>>{};
|
||||
for (final file in files) {
|
||||
map.putIfAbsent(_parentOf(file), () => []).add(file);
|
||||
final parent = '/${webdavParentSegmentsOf(file.path).join('/')}';
|
||||
map.putIfAbsent(parent, () => []).add(file);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
String _parentOf(CacheableFile file) {
|
||||
final stripped = file.path.replaceAll(_edgeSlashes, '');
|
||||
final segments = stripped.split('/');
|
||||
if (segments.length <= 1) return '/';
|
||||
segments.removeLast();
|
||||
return '/${segments.join('/')}';
|
||||
}
|
||||
|
||||
List<String> _segmentsOf(String folder) {
|
||||
final stripped = folder.replaceAll(_edgeSlashes, '');
|
||||
if (stripped.isEmpty) return const [];
|
||||
return stripped.split('/');
|
||||
}
|
||||
}
|
||||
|
||||
class _FolderHeader extends StatelessWidget {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 '../data/webdav_path.dart';
|
||||
|
||||
/// Document key prefix used by `ListFilesCache._documentId`.
|
||||
const String _folderCachePrefix = 'wd-folder-';
|
||||
@@ -42,18 +43,16 @@ List<CacheableFile> buildLocalCacheIndex(List<String> payloads) {
|
||||
/// 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.
|
||||
/// the given folder. Pass an empty list to search globally.
|
||||
List<CacheableFile> searchLocalCacheIndex(
|
||||
List<CacheableFile> index,
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
List<String> pathScope = const [],
|
||||
}) {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
final scopePrefix = webdavFolderPath(pathScope);
|
||||
return [
|
||||
for (final file in index)
|
||||
if (file.lowerName.contains(needle) &&
|
||||
|
||||
@@ -21,6 +21,18 @@ import 'share_password_sheet.dart';
|
||||
String _two(int n) => n.toString().padLeft(2, '0');
|
||||
String _isoDate(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}';
|
||||
|
||||
/// Token of the Talk room a share points to; null for other share kinds.
|
||||
String? talkRoomTokenOf(Share share) {
|
||||
final token = share.shareWith;
|
||||
return share.isRoom && token != null && token.isNotEmpty ? token : null;
|
||||
}
|
||||
|
||||
/// Opens the Talk room and closes the sheet it was opened from.
|
||||
void openShareInTalk(BuildContext context, String token) {
|
||||
AppRoutes.openChatByToken(context, token);
|
||||
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
IconData shareIcon(Share share) {
|
||||
if (share.isPublicLink) return Icons.link;
|
||||
if (share.isGroup) return Icons.groups_outlined;
|
||||
@@ -85,6 +97,9 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _update(ShareUpdateParams params) =>
|
||||
_mutate(() => FileSharingApi().update(_share.id, params));
|
||||
|
||||
List<SharePreset> _availablePresets() => [
|
||||
SharePreset.readOnly,
|
||||
SharePreset.edit,
|
||||
@@ -93,15 +108,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
|
||||
Future<void> _setPreset(SharePreset preset) async {
|
||||
final caps = context.read<NextcloudCapabilitiesCubit>();
|
||||
await _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
ShareUpdateParams(
|
||||
permissions: permissionsFor(
|
||||
preset,
|
||||
allowReshare: caps.canReshare,
|
||||
isFolder: _share.isFolder,
|
||||
),
|
||||
await _update(
|
||||
ShareUpdateParams(
|
||||
permissions: permissionsFor(
|
||||
preset,
|
||||
allowReshare: caps.canReshare,
|
||||
isFolder: _share.isFolder,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -121,20 +133,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
lastDate: lastDate,
|
||||
);
|
||||
if (picked == null) return;
|
||||
await _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
ShareUpdateParams(expireDate: _isoDate(picked)),
|
||||
),
|
||||
);
|
||||
await _update(ShareUpdateParams(expireDate: _isoDate(picked)));
|
||||
}
|
||||
|
||||
Future<void> _clearExpiry() => _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
const ShareUpdateParams(expireDate: ''),
|
||||
),
|
||||
);
|
||||
Future<void> _clearExpiry() =>
|
||||
_update(const ShareUpdateParams(expireDate: ''));
|
||||
|
||||
Future<void> _changePassword() async {
|
||||
final caps = context.read<NextcloudCapabilitiesCubit>();
|
||||
@@ -144,27 +147,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
policyHint: caps.passwordPolicyHint,
|
||||
);
|
||||
if (value == null || value.isEmpty || !mounted) return;
|
||||
await _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
ShareUpdateParams(password: value),
|
||||
),
|
||||
);
|
||||
await _update(ShareUpdateParams(password: value));
|
||||
}
|
||||
|
||||
Future<void> _clearPassword() => _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
const ShareUpdateParams(password: ''),
|
||||
),
|
||||
);
|
||||
|
||||
void _openInTalk() {
|
||||
final token = _share.shareWith;
|
||||
if (token == null || token.isEmpty) return;
|
||||
AppRoutes.openChatByToken(context, token);
|
||||
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
|
||||
}
|
||||
Future<void> _clearPassword() =>
|
||||
_update(const ShareUpdateParams(password: ''));
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
// Use showDialog directly (not asDialog) so we get the AsyncDialogAction's
|
||||
@@ -217,12 +204,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
),
|
||||
),
|
||||
|
||||
if (_share.isRoom && (_share.shareWith?.isNotEmpty ?? false)) ...[
|
||||
if (talkRoomTokenOf(_share) case final token?) ...[
|
||||
const Divider(height: 24),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
|
||||
title: const Text('Im Talk-Chat öffnen'),
|
||||
onTap: _busy ? null : _openInTalk,
|
||||
onTap: _busy ? null : () => openShareInTalk(context, token),
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
@@ -47,19 +47,13 @@ class _ShareSheetBody extends StatefulWidget {
|
||||
|
||||
class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
||||
late final String _ocsPath = ocsPathOf(widget.file);
|
||||
Future<List<Share>>? _future;
|
||||
late Future<List<Share>> _future = FileSharingApi().listForPath(_ocsPath);
|
||||
bool _busy = false;
|
||||
|
||||
/// Last resolved share list — used by the create-link gate without depending
|
||||
/// on the FutureBuilder's snapshot.
|
||||
List<Share>? _lastShares;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = FileSharingApi().listForPath(_ocsPath);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
// Block body: an arrow would return the Future, which setState rejects.
|
||||
setState(() {
|
||||
@@ -144,26 +138,19 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
||||
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
|
||||
title: const Text('Person oder Gruppe hinzufügen'),
|
||||
enabled: !_busy,
|
||||
onTap: _busy ? null : _addSharee,
|
||||
onTap: _addSharee,
|
||||
),
|
||||
if (caps.canCreatePublicLinks && _canAddLink(caps))
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.add_link)),
|
||||
title: const Text('Öffentlichen Link erstellen'),
|
||||
enabled: !_busy,
|
||||
onTap: _busy ? null : _createPublicLink,
|
||||
onTap: _createPublicLink,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openInTalk(Share share) {
|
||||
final token = share.shareWith;
|
||||
if (token == null || token.isEmpty) return;
|
||||
AppRoutes.openChatByToken(context, token);
|
||||
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// Whether another public link may be created — hidden once one exists on a
|
||||
/// server that disallows multiple links.
|
||||
bool _canAddLink(NextcloudCapabilitiesCubit caps) {
|
||||
@@ -271,11 +258,11 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
||||
tooltip: 'Link kopieren',
|
||||
onPressed: () => copyToClipboard(context, share.url!),
|
||||
),
|
||||
if (share.isRoom && (share.shareWith?.isNotEmpty ?? false))
|
||||
if (talkRoomTokenOf(share) case final token?)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
tooltip: 'Im Talk-Chat öffnen',
|
||||
onPressed: () => _openInTalk(share),
|
||||
onPressed: () => openShareInTalk(context, token),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache
|
||||
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../../../utils/file_clipboard.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
|
||||
/// Banner that appears at the top of a Files folder while there is something
|
||||
/// in the file clipboard. Shows the cut/copy state and offers a "Hier
|
||||
@@ -26,16 +27,6 @@ class ClipboardBanner extends StatefulWidget {
|
||||
class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
bool _busy = false;
|
||||
|
||||
// All paths here are relative to the WebDAV root (matching `CacheableFile.path`).
|
||||
// Root is the empty string ''. Folders end with '/'.
|
||||
String _normalised(String path) {
|
||||
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return stripped.isEmpty ? '' : '$stripped/';
|
||||
}
|
||||
|
||||
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
// Disabled when:
|
||||
// - clipboard is empty
|
||||
// - we'd be pasting a folder into itself or one of its descendants
|
||||
@@ -43,14 +34,14 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
bool get _canPaste {
|
||||
final cb = FileClipboard.instance;
|
||||
if (cb.isEmpty) return false;
|
||||
final dst = _normalised(widget.currentFolder);
|
||||
final dst = webdavFolderPath(webdavSegmentsOf(widget.currentFolder));
|
||||
var atLeastOneActionable = false;
|
||||
for (final f in cb.files) {
|
||||
if (f.isDirectory) {
|
||||
final src = _normalised(f.path);
|
||||
final src = webdavFolderPath(webdavSegmentsOf(f.path));
|
||||
if (dst == src || dst.startsWith(src)) return false;
|
||||
}
|
||||
final destination = _joinPath(
|
||||
final destination = webdavJoin(
|
||||
widget.currentFolder,
|
||||
f.name,
|
||||
isDirectory: f.isDirectory,
|
||||
@@ -60,15 +51,6 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
return atLeastOneActionable;
|
||||
}
|
||||
|
||||
// Cache key format used by ListFilesCache (matches FilesBloc's pathString:
|
||||
// relative, no leading or trailing slash; root is '/').
|
||||
String _parentCacheKey(String relativePath) {
|
||||
final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (!stripped.contains('/')) return '/';
|
||||
final parts = stripped.split('/')..removeLast();
|
||||
return parts.isEmpty ? '/' : parts.join('/');
|
||||
}
|
||||
|
||||
Future<void> _paste() async {
|
||||
final cb = FileClipboard.instance;
|
||||
if (_busy || !_canPaste) return;
|
||||
@@ -79,7 +61,7 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
try {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
for (final file in cb.files) {
|
||||
final destination = _joinPath(
|
||||
final destination = webdavJoin(
|
||||
widget.currentFolder,
|
||||
file.name,
|
||||
isDirectory: file.isDirectory,
|
||||
@@ -91,7 +73,9 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
PathUri.parse(file.path),
|
||||
PathUri.parse(destination),
|
||||
);
|
||||
invalidatedSourceFolders.add(_parentCacheKey(file.path));
|
||||
invalidatedSourceFolders.add(
|
||||
listFilesCacheKey(webdavParentSegmentsOf(file.path)),
|
||||
);
|
||||
} else {
|
||||
await webdav.copy(
|
||||
PathUri.parse(file.path),
|
||||
@@ -105,9 +89,9 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
// After cut, the source folders no longer contain the moved files. Drop
|
||||
// their cached listings so the next visit fetches fresh data instead of
|
||||
// briefly showing the moved file as still present.
|
||||
for (final folder in invalidatedSourceFolders) {
|
||||
await ListFilesCache.invalidate(folder);
|
||||
}
|
||||
await Future.wait(
|
||||
invalidatedSourceFolders.map(ListFilesCache.invalidate),
|
||||
);
|
||||
if (operation == FileClipboardOperation.cut) cb.clear();
|
||||
widget.onPasteDone();
|
||||
} finally {
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/downloads/download_trigger.dart';
|
||||
import '../../../../widget/prompt_dialog.dart';
|
||||
import '../../talk/widgets/highlighted_linkify.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
import '../sharing/share_sheet.dart';
|
||||
import 'file_details_sheet.dart';
|
||||
import 'file_leading.dart';
|
||||
@@ -26,7 +27,7 @@ import 'file_leading.dart';
|
||||
class FileElement extends StatefulWidget {
|
||||
final CacheableFile file;
|
||||
final List<String> path;
|
||||
final void Function() refetch;
|
||||
final VoidCallback refetch;
|
||||
|
||||
/// When non-null, occurrences of this string in the file name are visually
|
||||
/// highlighted in the tile title. Used by the Files search delegate.
|
||||
@@ -101,7 +102,7 @@ class _FileElementState extends State<FileElement>
|
||||
if (widget.file.isDirectory) {
|
||||
AppRoutes.openFolder(
|
||||
context,
|
||||
widget.path.toList()..add(widget.file.name),
|
||||
[...widget.path, widget.file.name],
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -116,18 +117,6 @@ class _FileElementState extends State<FileElement>
|
||||
);
|
||||
}
|
||||
|
||||
// All paths here are relative to the WebDAV root (matching CacheableFile.path).
|
||||
// Root parent is the empty string ''. Folders end with '/'.
|
||||
String _parentPathOf(String path) {
|
||||
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (!stripped.contains('/')) return '';
|
||||
final parts = stripped.split('/')..removeLast();
|
||||
return parts.isEmpty ? '' : '${parts.join('/')}/';
|
||||
}
|
||||
|
||||
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
void _rename() {
|
||||
if (guardDemoAction(context)) return;
|
||||
showPromptDialog(
|
||||
@@ -138,9 +127,8 @@ class _FileElementState extends State<FileElement>
|
||||
initialValue: widget.file.name,
|
||||
onConfirm: (newName) async {
|
||||
if (newName.isEmpty || newName == widget.file.name) return;
|
||||
final parent = _parentPathOf(widget.file.path);
|
||||
final destination = _joinPath(
|
||||
parent,
|
||||
final destination = webdavJoin(
|
||||
webdavFolderPath(webdavParentSegmentsOf(widget.file.path)),
|
||||
newName,
|
||||
isDirectory: widget.file.isDirectory,
|
||||
);
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../data/sort_options.dart';
|
||||
|
||||
/// Sort direction and sort key pickers, read from and persisted to the file
|
||||
/// settings.
|
||||
class FilesSortActions extends StatelessWidget {
|
||||
final SortOption currentSort;
|
||||
final bool ascending;
|
||||
final ValueChanged<bool> onDirectionChanged;
|
||||
final ValueChanged<SortOption> onSortChanged;
|
||||
|
||||
const FilesSortActions({
|
||||
required this.currentSort,
|
||||
required this.ascending,
|
||||
required this.onDirectionChanged,
|
||||
required this.onSortChanged,
|
||||
super.key,
|
||||
});
|
||||
const FilesSortActions({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final (currentSort, ascending) = context.select(
|
||||
(SettingsCubit c) =>
|
||||
(c.state.fileSettings.sortBy, c.state.fileSettings.ascending),
|
||||
);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -46,7 +44,7 @@ class FilesSortActions extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: onDirectionChanged,
|
||||
onSelected: (e) => settings.val(write: true).fileSettings.ascending = e,
|
||||
),
|
||||
PopupMenuButton<SortOption>(
|
||||
tooltip:
|
||||
@@ -70,7 +68,7 @@ class FilesSortActions extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: onSortChanged,
|
||||
onSelected: (e) => settings.val(write: true).fileSettings.sortBy = e,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user