simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
+25
View File
@@ -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('/');
+10 -46
View File
@@ -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,
+67 -71
View File
@@ -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),
),
],
+5 -18
View File
@@ -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 {
+5 -17
View File
@@ -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,
),
],
);