diff --git a/lib/view/pages/files/fileUploadDialog.dart b/lib/view/pages/files/fileUploadDialog.dart index 0708cdf..e69de29 100644 --- a/lib/view/pages/files/fileUploadDialog.dart +++ b/lib/view/pages/files/fileUploadDialog.dart @@ -1,233 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:async/async.dart'; -import 'package:flutter/material.dart'; -import 'package:nextcloud/nextcloud.dart'; - -import '../../../api/marianumcloud/webdav/webdavApi.dart'; - -class FileUploadDialog extends StatefulWidget { - final String localPath; - final List remotePath; - final String fileName; - final void Function() onUploadFinished; - - final bool doShowFinish; - - const FileUploadDialog({super.key, required this.localPath, required this.remotePath, required this.fileName, required this.onUploadFinished, this.doShowFinish = true}); - - @override - State createState() => _FileUploadDialogState(); -} - -class _FileUploadDialogState extends State { - FileUploadState state = FileUploadState.naming; - CancelableOperation? cancelableOperation; - late String targetFileName; - late String remoteFolderName; - late String fullRemotePath = "${widget.remotePath.join("/")}/$targetFileName"; - String? lastError; - - TextEditingController fileNameController = TextEditingController(); - - - Future upload({bool override = false}) async { - setState(() { - state = FileUploadState.upload; - }); - - var webdavClient = await WebdavApi.webdav; - - if(!override) { - setState(() { - state = FileUploadState.checkConflict; - }); - var result = (await webdavClient.propfind(PathUri.parse(widget.remotePath.join('/')))).responses; - if(result.any((element) => element.href!.endsWith('/$targetFileName'))) { - setState(() { - state = FileUploadState.conflict; - }); - return; - } else { - setState(() { - state = FileUploadState.upload; - }); - } - } - - var uploadTask = webdavClient.putFile(File(widget.localPath), FileStat.statSync(widget.localPath), PathUri.parse(fullRemotePath)); // TODO use onProgress from putFile - uploadTask.then(Future.value).catchError((e) { - setState(() { - state = FileUploadState.error; - }); - return null; - }); - - - cancelableOperation = CancelableOperation.fromFuture( - uploadTask, - onCancel: () => log('Upload cancelled'), - ); - - cancelableOperation!.then((value) { - setState(() { - state = FileUploadState.done; - }); - }); - } - - void cancel() { - cancelableOperation?.cancel(); - setState(() { - state = FileUploadState.naming; - }); - } - - @override - void initState() { - super.initState(); - targetFileName = widget.fileName; - remoteFolderName = widget.remotePath.isNotEmpty ? widget.remotePath.last : '/'; - fileNameController.text = widget.fileName; - } - - @override - Widget build(BuildContext context) { - if(state == FileUploadState.naming) { - return AlertDialog( - title: const Text('Datei hochladen'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: fileNameController, - onChanged: (input) { - targetFileName = input; - }, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Dateiname', - ), - ), - ], - ), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Abbrechen')), - TextButton(onPressed: () async { - upload(); - }, child: const Text('Hochladen')), - ], - - ); - } - - if(state == FileUploadState.conflict) { - return AlertDialog( - icon: const Icon(Icons.error_outline), - title: const Text('Datei konflikt'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Es gibt bereits eine Datei mit dem Namen $targetFileName in dem ausgewählten Ordner '$remoteFolderName'", textAlign: TextAlign.center), - ], - ), - actions: [ - TextButton(onPressed: () { - setState(() { - state = FileUploadState.naming; - }); - }, child: const Text('Datei umbenennen')), - TextButton(onPressed: () { - upload(override: true); - }, child: const Text('Datei überschreiben')), - ], - - ); - } - - if(state == FileUploadState.upload || state == FileUploadState.checkConflict) { - return AlertDialog( - icon: const Icon(Icons.upload), - title: const Text('Hochladen'), - content: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Visibility( - visible: state == FileUploadState.upload, - replacement: const Text('Prüfe auf dateikonflikte...'), - child: const Text('Upload läuft!\nDies kann je nach Dateigröße einige Zeit dauern...', textAlign: TextAlign.center), - ), - const SizedBox(height: 30), - const CircularProgressIndicator() - ], - ), - actions: const [ - // TODO implement working upload cancelling - // TextButton(onPressed: () { - // cancel(); - // }, child: const Text("Abbrechen")), - ], - - ); - } - - if(state == FileUploadState.done) { - widget.onUploadFinished(); - if(!widget.doShowFinish) { - Navigator.of(context).pop(); - return const SizedBox.shrink(); - } - return AlertDialog( - icon: const Icon(Icons.done), - title: const Text('Upload fertig'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Die Datei wurde erfolgreich nach '$remoteFolderName' hochgeladen!", textAlign: TextAlign.center), - ], - ), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Fertig')), - ], - - ); - } - - if(state == FileUploadState.error) { - return AlertDialog( - icon: const Icon(Icons.error_outline), - title: const Text('Fehler'), - content: const Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Es ist ein Fehler aufgetreten!', textAlign: TextAlign.center), - ], - ), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Schlißen')), - ], - - ); - } - - throw UnimplementedError('Invalid state'); - - } -} - -enum FileUploadState { - naming, - checkConflict, - conflict, - upload, - done, - error -} diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 28f991f..8068408 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:nextcloud/nextcloud.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:provider/provider.dart'; import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; @@ -15,8 +16,8 @@ import '../../../storage/base/settingsProvider.dart'; import '../../../widget/loadingSpinner.dart'; import '../../../widget/placeholderView.dart'; import '../../../widget/filePick.dart'; -import 'fileUploadDialog.dart'; import 'fileElement.dart'; +import 'filesUploadDialog.dart'; class Files extends StatefulWidget { final List path; @@ -88,7 +89,6 @@ class _FilesState extends State { ListFilesCache( path: widget.path.isEmpty ? '/' : widget.path.join('/'), onUpdate: (ListFilesResponse d) { - if(!context.mounted) return; // prevent setState when widget is possibly already disposed d.files.removeWhere((element) => element.name.isEmpty || element.name == widget.path.lastOrNull()); setState(() { data = d; @@ -97,6 +97,18 @@ class _FilesState extends State { ); } + Future mediaUpload(List? paths) async { + if(paths == null) return; + + pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog(filePaths: paths, remotePath: widget.path.join('/'), onUploadFinished: (uploadedFilePaths) => _query()), + ); + + return; + } + @override Widget build(BuildContext context) { var files = data?.sortBy( @@ -144,7 +156,7 @@ class _FilesState extends State { children: [ Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName) + Text(SortOptions.getOption(key).displayName), ], ) )).toList(), @@ -197,7 +209,6 @@ class _FilesState extends State { leading: const Icon(Icons.upload_file), title: const Text('Aus Dateien hochladen'), onTap: () { - context.loaderOverlay.show(); FilePick.documentPick().then(mediaUpload); Navigator.of(context).pop(); }, @@ -208,9 +219,8 @@ class _FilesState extends State { leading: const Icon(Icons.add_a_photo_outlined), title: const Text('Aus Gallerie hochladen'), onTap: () { - context.loaderOverlay.show(); - FilePick.galleryPick().then((value) { - mediaUpload(value?.path); + FilePick.multipleGalleryPick().then((value) { + if(value != null) mediaUpload(value.map((e) => e.path).toList()); }); Navigator.of(context).pop(); }, @@ -239,15 +249,4 @@ class _FilesState extends State { ) ); } - - Future mediaUpload(String? path) async { - context.loaderOverlay.hide(); - - if(path == null) { - return; - } - - var fileName = path.split(Platform.pathSeparator).last; - showDialog(context: context, builder: (context) => FileUploadDialog(localPath: path, remotePath: widget.path, fileName: fileName, onUploadFinished: _query), barrierDismissible: false); - } } diff --git a/lib/view/pages/files/filesUploadDialog.dart b/lib/view/pages/files/filesUploadDialog.dart new file mode 100644 index 0000000..72803c2 --- /dev/null +++ b/lib/view/pages/files/filesUploadDialog.dart @@ -0,0 +1,311 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:loader_overlay/loader_overlay.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../widget/confirmDialog.dart'; +import '../../../widget/focusBehaviour.dart'; + +class FilesUploadDialog extends StatefulWidget { + final List filePaths; + final String remotePath; + final void Function(List uploadedFilePaths) onUploadFinished; + final bool uniqueNames; + + const FilesUploadDialog({super.key, required this.filePaths, required this.remotePath, required this.onUploadFinished, this.uniqueNames = false}); + + @override + State createState() => _FilesUploadDialogState(); +} + +class UploadableFile { + TextEditingController fileNameController = TextEditingController(); + String filePath; + String fileName; + double? _uploadProgress; + bool isConflicting = false; + + UploadableFile(this.filePath, this.fileName); +} + + +class _FilesUploadDialogState extends State { + late List _uploadableFiles; + bool _isUploading = false; + double _overallProgressValue = 0.0; + String _infoText = ''; + + @override + void initState() { + super.initState(); + + _uploadableFiles = widget.filePaths.map((filePath) { + var fileName = filePath.split(Platform.pathSeparator).last; + return UploadableFile(filePath, fileName); + }).toList(); + } + + void showHttpErrorCode(int httpErrorCode){ + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Ein Fehler ist aufgetreten'), + contentPadding: const EdgeInsets.all(10), + content: Text('Error code: $httpErrorCode'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen', textAlign: TextAlign.center), + ), + ], + ) + ); + } + + Future uploadFiles({bool override = false}) async { + setState(() { + _isUploading = true; + _infoText = 'Vorbereiten'; + for (var file in _uploadableFiles) { + file.isConflicting = false; + } + }); + + var webdavClient = await WebdavApi.webdav; + + if (!override) { + var result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + var conflictingFiles = _uploadableFiles.where((file) { + var fileName = file.fileName; + return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName')); + }).toList(); + + if(conflictingFiles.isNotEmpty) { + bool replaceFiles = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => 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.', + textAlign: TextAlign.left, + ) : + SingleChildScrollView( + child: Text( + '${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}', + textAlign: TextAlign.left, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, 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', + ), + ); + + }, + child: const Text('Überschreiben', textAlign: TextAlign.center), + ), + ], + ) + ); + + if(!replaceFiles) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + for (var element in conflictingFiles) { + element.isConflicting = true; + } + }); + return; + } + } + } + + var uploadetFilePaths = []; + for (var file in _uploadableFiles) { + var fileName = file.fileName; + var filePath = file.filePath; + + if(widget.uniqueNames) fileName = '${fileName.split('.').first}-${const Uuid().v4()}.${fileName.split('.').last}'; + + var fullRemotePath = '${widget.remotePath}/$fileName'; + + setState(() { + _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; + }); + + var uploadTask = await webdavClient.putFile( + File(filePath), + FileStat.statSync(filePath), + PathUri.parse(fullRemotePath), + onProgress: (progress) { + setState(() { + file._uploadProgress = progress; + _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); + }); + }, + ); + + if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + Navigator.of(context).pop(); + showHttpErrorCode(uploadTask.statusCode); + } else { + uploadetFilePaths.add(fullRemotePath); + } + } + + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + Navigator.of(context).pop(); + widget.onUploadFinished(uploadetFilePaths); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Dateien hochladen'), + automaticallyImplyLeading: false, + ), + body: LoaderOverlay( + overlayWholeScreen: true, + child: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: _uploadableFiles.length, + itemBuilder: (context, index) { + final currentFile = _uploadableFiles[index]; + currentFile.fileNameController.text = currentFile.fileName; + return ListTile( + title: TextField( + readOnly: _isUploading, + controller: currentFile.fileNameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + label: Text('Datei ${index+1}'), + errorText: currentFile.isConflicting ? 'existiert bereits' : null, + errorStyle: const TextStyle(color: Colors.red), + ), + 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; + }); + } + }, + ), + subtitle: _isUploading && (currentFile._uploadProgress ?? 0) < 1 ? LinearProgressIndicator( + value: currentFile._uploadProgress, + borderRadius: const BorderRadius.all(Radius.circular(2)), + ) : null, + trailing: Container( + width: 24, + height: 24, + padding: EdgeInsets.zero, + child: IconButton( + tooltip: 'Datei entfernen', + padding: EdgeInsets.zero, + onPressed: () { + if(!_isUploading) { + if(_uploadableFiles.length-1 <= 0) Navigator.of(context).pop(); + setState(() { + _uploadableFiles.removeAt(index); + }); + } + }, + icon: const Icon(Icons.delete_outlined), + ), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 15, right: 15, bottom: 15, top: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Visibility( + visible: !_isUploading, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + ), + const Expanded(child: SizedBox.shrink()), + Visibility( + visible: _isUploading, + replacement: TextButton( + onPressed: () => uploadFiles(override: widget.uniqueNames), + child: const Text('Hochladen'), + ), + child: Visibility( + visible: _infoText.length < 5, + replacement: Row( + children: [ + Text(_infoText), + const SizedBox(width: 15), + CircularProgressIndicator(value: _overallProgressValue), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: _overallProgressValue), + Center(child: Text(_infoText)), + ], + ), + ), + + + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/talk/components/chatTextfield.dart b/lib/view/pages/talk/components/chatTextfield.dart index b77928d..3628006 100644 --- a/lib/view/pages/talk/components/chatTextfield.dart +++ b/lib/view/pages/talk/components/chatTextfield.dart @@ -1,10 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:loader_overlay/loader_overlay.dart'; import 'package:nextcloud/nextcloud.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:provider/provider.dart'; -import 'package:uuid/uuid.dart'; import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart'; import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart'; @@ -15,7 +14,7 @@ import '../../../../model/chatList/chatProps.dart'; import '../../../../storage/base/settingsProvider.dart'; import '../../../../widget/filePick.dart'; import '../../../../widget/focusBehaviour.dart'; -import '../../files/fileUploadDialog.dart'; +import '../../files/filesUploadDialog.dart'; class ChatTextfield extends StatefulWidget { final String sendToToken; @@ -34,32 +33,37 @@ class _ChatTextfieldState extends State { Provider.of(context, listen: false).run(); } - Future mediaUpload(String? path) async { - context.loaderOverlay.hide(); - - if(path == null) { - return; + void share(String shareFolder, List filePaths) { + for (var element in filePaths) { + var fileName = element.split(Platform.pathSeparator).last; + FileSharingApi().share(FileSharingApiParams( + shareType: 10, + shareWith: widget.sendToToken, + path: '$shareFolder/$fileName', + )).then((value) => _query()); } + } + + Future mediaUpload(List? paths) async { + if (paths == null) return; - var filename = "${path.split("/").last.split(".").first}-${const Uuid().v4()}.${path.split(".").last}"; var shareFolder = 'MarianumMobile'; WebdavApi.webdav.then((webdav) { webdav.mkcol(PathUri.parse('/$shareFolder')); }); - showDialog(context: context, builder: (context) => FileUploadDialog( - doShowFinish: false, - fileName: filename, - localPath: path, - remotePath: [shareFolder], - onUploadFinished: () { - FileSharingApi().share(FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$filename', - )).then((value) => _query()); - }, - ), barrierDismissible: false); + pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: paths, + remotePath: shareFolder, + onUploadFinished: (uploadedFilePaths) { + share(shareFolder, uploadedFilePaths); + }, + uniqueNames: true, + ), + ); } void setDraft(String text) { @@ -97,7 +101,6 @@ class _ChatTextfieldState extends State { leading: const Icon(Icons.file_open), title: const Text('Aus Dateien auswählen'), onTap: () { - context.loaderOverlay.show(); FilePick.documentPick().then(mediaUpload); Navigator.of(context).pop(); }, @@ -108,9 +111,8 @@ class _ChatTextfieldState extends State { leading: const Icon(Icons.image), title: const Text('Aus Gallerie auswählen'), onTap: () { - context.loaderOverlay.show(); - FilePick.galleryPick().then((value) { - mediaUpload(value?.path); + FilePick.multipleGalleryPick().then((value) { + if(value != null) mediaUpload(value.map((e) => e.path).toList()); }); Navigator.of(context).pop(); }, diff --git a/lib/widget/filePick.dart b/lib/widget/filePick.dart index e5bbd94..b9d7dbb 100644 --- a/lib/widget/filePick.dart +++ b/lib/widget/filePick.dart @@ -13,8 +13,17 @@ class FilePick { return null; } - static Future documentPick() async { - var result = await FilePicker.platform.pickFiles(); - return result?.files.single.path; + static Future?> multipleGalleryPick() async { + final pickedImages = await _picker.pickMultiImage(); + if(pickedImages.isNotEmpty) { + return pickedImages; + } + return null; + } + + static Future?> documentPick() async { + var result = await FilePicker.platform.pickFiles(allowMultiple: true); + var paths = result?.files.nonNulls.map((e) => e.path).toList(); + return paths?.nonNulls.toList(); } }