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:
+16
-296
@@ -2,12 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nextcloud/nextcloud.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_cache.dart';
|
||||
import '../../../api/marianumcloud/webdav/webdav_api.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';
|
||||
@@ -15,49 +11,13 @@ 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 '../../../utils/file_clipboard.dart';
|
||||
import '../../../widget/async_action_button.dart';
|
||||
import '../../../widget/file_pick.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'data/sort_options.dart';
|
||||
import 'files_upload_dialog.dart';
|
||||
import 'widgets/add_file_menu.dart';
|
||||
import 'widgets/clipboard_banner.dart';
|
||||
import 'widgets/file_element.dart';
|
||||
|
||||
class BetterSortOption {
|
||||
String displayName;
|
||||
int Function(CacheableFile, CacheableFile) compare;
|
||||
IconData icon;
|
||||
|
||||
BetterSortOption({required this.displayName, required this.icon, required this.compare});
|
||||
}
|
||||
|
||||
enum SortOption { name, date, size }
|
||||
|
||||
class SortOptions {
|
||||
static Map<SortOption, BetterSortOption> options = {
|
||||
SortOption.name: BetterSortOption(
|
||||
displayName: 'Name',
|
||||
icon: Icons.sort_by_alpha_outlined,
|
||||
compare: (a, b) => a.name.compareTo(b.name),
|
||||
),
|
||||
SortOption.date: BetterSortOption(
|
||||
displayName: 'Datum',
|
||||
icon: Icons.history_outlined,
|
||||
compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!),
|
||||
),
|
||||
SortOption.size: BetterSortOption(
|
||||
displayName: 'Größe',
|
||||
icon: Icons.sd_card_outlined,
|
||||
compare: (a, b) {
|
||||
if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0;
|
||||
if (a.size == null) return 0;
|
||||
if (b.size == null) return 1;
|
||||
return a.size!.compareTo(b.size!);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
static BetterSortOption getOption(SortOption option) => options[option]!;
|
||||
}
|
||||
import 'widgets/files_sort_actions.dart';
|
||||
|
||||
class Files extends StatelessWidget {
|
||||
final List<String> path;
|
||||
@@ -89,6 +49,10 @@ class _FilesViewState extends State<_FilesView> {
|
||||
// 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();
|
||||
@@ -110,7 +74,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> mediaUpload(List<String>? paths) async {
|
||||
Future<void> _mediaUpload(List<String>? paths) async {
|
||||
if (paths == null) return;
|
||||
final bloc = context.read<FilesBloc>();
|
||||
unawaited(pushScreen(
|
||||
@@ -131,47 +95,16 @@ class _FilesViewState extends State<_FilesView> {
|
||||
appBar: AppBar(
|
||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||
actions: [
|
||||
PopupMenuButton<bool>(
|
||||
icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down),
|
||||
itemBuilder: (context) => [true, false]
|
||||
.map((e) => PopupMenuItem<bool>(
|
||||
value: e,
|
||||
enabled: e != currentSortDirection,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
e ? Icons.text_rotate_up : Icons.text_rotation_down,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Text(e ? 'Aufsteigend' : 'Absteigend'),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onSelected: (e) {
|
||||
FilesSortActions(
|
||||
currentSort: currentSort,
|
||||
ascending: currentSortDirection,
|
||||
onDirectionChanged: (e) {
|
||||
setState(() {
|
||||
currentSortDirection = e;
|
||||
settings.val(write: true).fileSettings.ascending = e;
|
||||
});
|
||||
},
|
||||
),
|
||||
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.of(context).colorScheme.onSurface),
|
||||
const SizedBox(width: 15),
|
||||
Text(SortOptions.getOption(key).displayName),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onSelected: (e) {
|
||||
onSortChanged: (e) {
|
||||
setState(() {
|
||||
currentSort = e;
|
||||
settings.val(write: true).fileSettings.sortBy = e;
|
||||
@@ -183,12 +116,12 @@ class _FilesViewState extends State<_FilesView> {
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'uploadFile',
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
onPressed: () => _showAddDialog(context, bloc),
|
||||
onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
|
||||
ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
|
||||
Expanded(
|
||||
child: LoadableStateConsumer<FilesBloc, FilesState>(
|
||||
isReady: (state) => state.listing != null,
|
||||
@@ -214,217 +147,4 @@ class _FilesViewState extends State<_FilesView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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('/')}/';
|
||||
|
||||
void _showAddDialog(BuildContext context, FilesBloc bloc) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => SimpleDialog(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.create_new_folder_outlined),
|
||||
title: const Text('Ordner erstellen'),
|
||||
onTap: () {
|
||||
Navigator.of(dialogCtx).pop();
|
||||
_showCreateFolderDialog(context, bloc);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upload_file),
|
||||
title: const Text('Aus Dateien hochladen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_a_photo_outlined),
|
||||
title: const Text('Aus Galerie hochladen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if (value != null) mediaUpload(value.map((e) => e.path).toList());
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () {
|
||||
FilePick.cameraPick().then((image) {
|
||||
if (image != null) mediaUpload([image.path]);
|
||||
});
|
||||
Navigator.of(dialogCtx).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());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClipboardBanner extends StatefulWidget {
|
||||
const _ClipboardBanner({required this.currentFolder, required this.onPasteDone});
|
||||
final String currentFolder;
|
||||
final void Function() onPasteDone;
|
||||
|
||||
@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) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Einfügen teilweise fehlgeschlagen'),
|
||||
content: SingleChildScrollView(child: Text(errors.join('\n\n'))),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user