refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/file_pick.dart';
|
||||
|
||||
/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …).
|
||||
/// [onPickedFiles] receives selected/captured file paths (gallery, file picker
|
||||
/// or camera) and is responsible for kicking off the upload flow.
|
||||
void showAddFileSheet(
|
||||
BuildContext context, {
|
||||
required FilesBloc bloc,
|
||||
required Future<void> Function(List<String>? paths) onPickedFiles,
|
||||
}) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.create_new_folder_outlined),
|
||||
title: const Text('Ordner erstellen'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
_showCreateFolderDialog(context, bloc);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upload_file),
|
||||
title: const Text('Aus Dateien hochladen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(onPickedFiles);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_a_photo_outlined),
|
||||
title: const Text('Aus Galerie hochladen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if (value != null) onPickedFiles(value.map((e) => e.path).toList());
|
||||
});
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () {
|
||||
FilePick.cameraPick().then((image) {
|
||||
if (image != null) onPickedFiles([image.path]);
|
||||
});
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
||||
final inputController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Neuer Ordner'),
|
||||
content: TextField(
|
||||
controller: inputController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
AsyncDialogAction(
|
||||
confirmLabel: 'Ordner erstellen',
|
||||
onConfirm: () async {
|
||||
if (inputController.text.trim().isEmpty) {
|
||||
throw Exception('Bitte einen Namen eingeben.');
|
||||
}
|
||||
await bloc.createFolder(inputController.text.trim());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
|
||||
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../../../utils/file_clipboard.dart';
|
||||
import '../../../../widget/info_dialog.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
|
||||
/// einfügen" button.
|
||||
class ClipboardBanner extends StatefulWidget {
|
||||
final String currentFolder;
|
||||
final VoidCallback onPasteDone;
|
||||
|
||||
const ClipboardBanner({
|
||||
required this.currentFolder,
|
||||
required this.onPasteDone,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ClipboardBanner> createState() => _ClipboardBannerState();
|
||||
}
|
||||
|
||||
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
|
||||
// - every entry already lives in the current folder (paste would be a no-op)
|
||||
bool get _canPaste {
|
||||
final cb = FileClipboard.instance;
|
||||
if (cb.isEmpty) return false;
|
||||
final dst = _normalised(widget.currentFolder);
|
||||
var atLeastOneActionable = false;
|
||||
for (final f in cb.files) {
|
||||
if (f.isDirectory) {
|
||||
final src = _normalised(f.path);
|
||||
if (dst == src || dst.startsWith(src)) return false;
|
||||
}
|
||||
final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory);
|
||||
if (destination != f.path) atLeastOneActionable = true;
|
||||
}
|
||||
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;
|
||||
setState(() => _busy = true);
|
||||
final operation = cb.operation;
|
||||
final errors = <String>[];
|
||||
final invalidatedSourceFolders = <String>{};
|
||||
try {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
for (final file in cb.files) {
|
||||
final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory);
|
||||
if (destination == file.path) continue;
|
||||
try {
|
||||
if (operation == FileClipboardOperation.cut) {
|
||||
await webdav.move(PathUri.parse(file.path), PathUri.parse(destination));
|
||||
invalidatedSourceFolders.add(_parentCacheKey(file.path));
|
||||
} else {
|
||||
await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination));
|
||||
}
|
||||
} on Object catch (e) {
|
||||
errors.add('${file.name}: $e');
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
if (operation == FileClipboardOperation.cut) cb.clear();
|
||||
widget.onPasteDone();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
if (errors.isNotEmpty && mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errors.join('\n\n'),
|
||||
copyable: true,
|
||||
title: 'Einfügen teilweise fehlgeschlagen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: FileClipboard.instance,
|
||||
builder: (context, _) {
|
||||
final cb = FileClipboard.instance;
|
||||
if (cb.isEmpty) return const SizedBox.shrink();
|
||||
final cut = cb.operation == FileClipboardOperation.cut;
|
||||
final count = cb.files.length;
|
||||
final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente';
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
cut ? '$label verschieben' : '$label kopieren',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _busy || !_canPaste ? null : _paste,
|
||||
child: _busy
|
||||
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Hier einfügen'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Verwerfen',
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: _busy ? null : cb.clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,33 @@
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../utils/clipboard_helper.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
|
||||
/// Shows a modal bottom sheet with technical metadata about a single file or
|
||||
/// folder: full path, MIME type, size, timestamps, ETag.
|
||||
Future<void> showFileDetailsSheet(BuildContext context, CacheableFile file) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
|
||||
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')),
|
||||
),
|
||||
const Divider(),
|
||||
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
|
||||
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
|
||||
if (file.modifiedAt != null)
|
||||
_DetailRow(
|
||||
label: 'Geändert',
|
||||
value: '${Jiffy.parseFromDateTime(file.modifiedAt!).format(pattern: 'dd.MM.yyyy HH:mm')} '
|
||||
'(${Jiffy.parseFromDateTime(file.modifiedAt!).fromNow()})',
|
||||
),
|
||||
if (file.createdAt != null)
|
||||
_DetailRow(
|
||||
label: 'Erstellt',
|
||||
value: Jiffy.parseFromDateTime(file.createdAt!).format(pattern: 'dd.MM.yyyy HH:mm'),
|
||||
),
|
||||
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
void showFileDetailsSheet(BuildContext context, CacheableFile file) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: ListTile(
|
||||
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
|
||||
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')),
|
||||
),
|
||||
children: (_) => [
|
||||
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
|
||||
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
|
||||
if (file.modifiedAt != null)
|
||||
_DetailRow(
|
||||
label: 'Geändert',
|
||||
value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})',
|
||||
),
|
||||
if (file.createdAt != null)
|
||||
_DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()),
|
||||
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +39,7 @@ class _DetailRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -67,12 +52,7 @@ class _DetailRow extends StatelessWidget {
|
||||
IconButton(
|
||||
tooltip: 'Kopieren',
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('In Zwischenablage kopiert')),
|
||||
);
|
||||
},
|
||||
onPressed: () => copyToClipboard(context, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../utils/download_manager.dart';
|
||||
@@ -135,9 +135,10 @@ class _FileElementState extends State<FileElement> {
|
||||
],
|
||||
);
|
||||
}
|
||||
final modified = widget.file.modifiedAt ?? DateTime.now();
|
||||
return widget.file.isDirectory
|
||||
? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}')
|
||||
: Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}');
|
||||
? Text('geändert ${modified.formatRelative()}')
|
||||
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/sort_options.dart';
|
||||
|
||||
/// AppBar action buttons for sort direction (asc/desc) and sort field
|
||||
/// (name/date/size). Pure UI – owners pass current values + selection
|
||||
/// callbacks.
|
||||
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,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PopupMenuButton<bool>(
|
||||
icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down),
|
||||
itemBuilder: (context) => [true, false]
|
||||
.map((e) => PopupMenuItem<bool>(
|
||||
value: e,
|
||||
enabled: e != ascending,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down,
|
||||
color: theme.colorScheme.onSurface),
|
||||
const SizedBox(width: 15),
|
||||
Text(e ? 'Aufsteigend' : 'Absteigend'),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onSelected: onDirectionChanged,
|
||||
),
|
||||
PopupMenuButton<SortOption>(
|
||||
icon: const Icon(Icons.sort),
|
||||
itemBuilder: (context) => SortOptions.options.keys
|
||||
.map((key) => PopupMenuItem<SortOption>(
|
||||
value: key,
|
||||
enabled: key != currentSort,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(SortOptions.getOption(key).icon, color: theme.colorScheme.onSurface),
|
||||
const SizedBox(width: 15),
|
||||
Text(SortOptions.getOption(key).displayName),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onSelected: onSortChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user