From b4defb9eda87c5358a85e520e1ce5ba147476216 Mon Sep 17 00:00:00 2001 From: Pupsi28 <larslukasneuhaus@gmx.de> Date: Fri, 5 Apr 2024 18:16:12 +0200 Subject: [PATCH] added upload with multiple files --- lib/view/pages/files/files.dart | 36 +- lib/view/pages/files/filesUploadDialog.dart | 318 ++++++++++++++++++ .../pages/talk/components/chatTextfield.dart | 53 +-- lib/widget/filePick.dart | 7 +- 4 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 lib/view/pages/files/filesUploadDialog.dart diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index b377cbf..f0c9add 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -1,9 +1,11 @@ +import 'dart:developer'; 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 +17,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<String> path; @@ -90,7 +92,8 @@ class _FilesState extends State<Files> { ListFilesCache( path: widget.path.isEmpty ? '/' : widget.path.join('/'), onUpdate: (ListFilesResponse d) { - if(!context.mounted) return; // prevent setState when widget is possibly already disposed + log('_query'); + 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; @@ -99,6 +102,18 @@ class _FilesState extends State<Files> { ); } + void mediaUpload(List<String>? 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) { List<CacheableFile> files = data?.sortBy( @@ -149,7 +164,7 @@ class _FilesState extends State<Files> { 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(); @@ -204,7 +219,6 @@ class _FilesState extends State<Files> { leading: const Icon(Icons.upload_file), title: const Text('Aus Dateien hochladen'), onTap: () { - context.loaderOverlay.show(); FilePick.documentPick().then(mediaUpload); Navigator.of(context).pop(); }, @@ -215,9 +229,8 @@ class _FilesState extends State<Files> { 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); + if(value != null) mediaUpload([value.path]); }); Navigator.of(context).pop(); }, @@ -247,15 +260,4 @@ class _FilesState extends State<Files> { ) ); } - - void 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..9f9ce02 --- /dev/null +++ b/lib/view/pages/files/filesUploadDialog.dart @@ -0,0 +1,318 @@ +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/focusBehaviour.dart'; + +class FilesUploadDialog extends StatefulWidget { + final List<String> filePaths; + final String remotePath; + final void Function(List<String> uploadedFilePaths) onUploadFinished; + final bool uniqueNames; + + const FilesUploadDialog({super.key, required this.filePaths, required this.remotePath, required this.onUploadFinished, this.uniqueNames = false}); + + @override + State<FilesUploadDialog> 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<FilesUploadDialog> { + final List<UploadableFile> _uploadableFiles = []; + bool _isUploading = false; + double _progressValue = 0.0; + String _infoText = ''; + + @override + void initState() { + super.initState(); + + _uploadableFiles.addAll(widget.filePaths.map((filePath) { + String fileName = filePath.split(Platform.pathSeparator).last; + return UploadableFile(filePath, fileName); + })); + } + + void showErrorMessage(int errorCode){ + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Ein Fehler ist aufgetreten'), + contentPadding: const EdgeInsets.all(10), + content: Text('Error code: $errorCode'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen', textAlign: TextAlign.center), + ), + ], + ); + } + ); + } + + void uploadSelectedFiles({bool override = false}) async { + setState(() { + _isUploading = true; + _infoText = 'Vorbereiten'; + }); + + for (var element in _uploadableFiles) { + setState(() { + element.isConflicting = false; + }); + } + + WebDavClient webdavClient = await WebdavApi.webdav; + + if (!override) { + List<WebDavResponse> result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; + List<UploadableFile> conflictingFiles = []; + + for (var file in _uploadableFiles) { + String fileName = file.fileName; + if (result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName'))) { + // konflikt + conflictingFiles.add(file); + } + } + + if(conflictingFiles.isNotEmpty) { + bool replaceFiles = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return 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.\n' + '(Datei ${_uploadableFiles.indexOf(conflictingFiles.first)+1})', + textAlign: TextAlign.left, + ) : + Text( + '${conflictingFiles.length} Dateien mit den Namen ${conflictingFiles.map((e) => e.fileName).toList()} existieren bereits.\n' + '(Dateien ${conflictingFiles.map((e) => _uploadableFiles.indexOf(e)+1).toList()})', + textAlign: TextAlign.left, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Bearbeiten', textAlign: TextAlign.center), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Ersetzen', textAlign: TextAlign.center), + ), + ], + ); + } + ); + + if(!replaceFiles) { + for (var element in conflictingFiles) { + element.isConflicting = true; + } + setState(() { + _isUploading = false; + _progressValue = 0.0; + _infoText = ''; + }); + return; + } + } + } + + List<String> uploadetFilePaths = []; + for (var file in _uploadableFiles) { + String fileName = file.fileName; + String filePath = file.filePath; + + if(widget.uniqueNames) fileName = '${fileName.split('.').first}-${const Uuid().v4()}.${fileName.split('.').last}'; + + String fullRemotePath = '${widget.remotePath}/$fileName'; + + setState(() { + _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; + }); + + HttpClientResponse uploadTask = await webdavClient.putFile( + File(filePath), + FileStat.statSync(filePath), + PathUri.parse(fullRemotePath), + onProgress: (progress) { + setState(() { + file._uploadProgress = progress; + _progressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); + }); + }, + ); + + if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { + // error code + setState(() { + _isUploading = false; + _progressValue = 0.0; + _infoText = ''; + }); + Navigator.of(context).pop(); + showErrorMessage(uploadTask.statusCode); + } else { + uploadetFilePaths.add(fullRemotePath); + } + } + + setState(() { + _isUploading = false; + _progressValue = 0.0; + _infoText = ''; + }); + Navigator.of(context).pop(); + widget.onUploadFinished(uploadetFilePaths); + } + + @override + Widget build(BuildContext context) { + return 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, + leading: 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.close_outlined), + ), + ), + trailing: Container( + width: 24, + height: 24, + padding: EdgeInsets.zero, + child: IconButton( + tooltip: 'Namen löschen', + padding: EdgeInsets.zero, + onPressed: () { + if(!_isUploading) { + setState(() { + currentFile.fileName = ''; + }); + } + }, + 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: () => uploadSelectedFiles(override: widget.uniqueNames), + child: const Text('Hochladen'), + ), + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: _progressValue), + Center(child: Text(_infoText)), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/view/pages/talk/components/chatTextfield.dart b/lib/view/pages/talk/components/chatTextfield.dart index 5513d8a..09c81d7 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,34 +33,40 @@ class _ChatTextfieldState extends State<ChatTextfield> { Provider.of<ChatProps>(context, listen: false).run(); } - void mediaUpload(String? path) async { - context.loaderOverlay.hide(); - - if(path == null) { - return; + void share(String shareFolder, List<String> filePaths) { + for (var element in filePaths) { + String fileName = element.split(Platform.pathSeparator).last; + FileSharingApi().share(FileSharingApiParams( + shareType: 10, + shareWith: widget.sendToToken, + path: '$shareFolder/$fileName', + )).then((value) => _query()); } + } + + void mediaUpload(List<String>? paths) async { + if (paths == null) return; - String filename = "${path.split("/").last.split(".").first}-${const Uuid().v4()}.${path.split(".").last}"; String 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) { if(text.isNotEmpty) { settings.val(write: true).talkSettings.drafts[widget.sendToToken] = text; @@ -98,7 +103,6 @@ class _ChatTextfieldState extends State<ChatTextfield> { 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(); }, @@ -109,9 +113,8 @@ class _ChatTextfieldState extends State<ChatTextfield> { leading: const Icon(Icons.image), title: const Text('Aus Gallerie auswählen'), onTap: () { - context.loaderOverlay.show(); FilePick.galleryPick().then((value) { - mediaUpload(value?.path); + if(value != null) mediaUpload([value.path]); }); Navigator.of(context).pop(); }, diff --git a/lib/widget/filePick.dart b/lib/widget/filePick.dart index b37fef4..0d7c0e5 100644 --- a/lib/widget/filePick.dart +++ b/lib/widget/filePick.dart @@ -13,8 +13,9 @@ class FilePick { return null; } - static Future<String?> documentPick() async { - FilePickerResult? result = await FilePicker.platform.pickFiles(); - return result?.files.single.path; + static Future<List<String>?> documentPick() async { + FilePickerResult? result = await FilePicker.platform.pickFiles(allowMultiple: true); + List<String?>? paths = result?.files.nonNulls.map((e) => e.path).toList(); + return paths?.nonNulls.toList(); } } \ No newline at end of file