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