1
+ import 'dart:math' ;
2
+
3
+ import 'package:file_picker/file_picker.dart' ;
1
4
import 'package:flutter/material.dart' ;
2
5
import 'dialog.dart' ;
3
6
@@ -44,21 +47,75 @@ class TopicTextEditingController extends TextEditingController {
44
47
45
48
enum ContentValidationError {
46
49
empty,
47
- tooLong;
50
+ tooLong,
51
+ uploadInProgress;
48
52
49
- // Later: upload in progress; quote-and-reply in progress
53
+ // Later: quote-and-reply in progress
50
54
51
55
String message () {
52
56
switch (this ) {
53
57
case ContentValidationError .tooLong:
54
58
return "Message length shouldn't be greater than 10000 characters." ;
55
59
case ContentValidationError .empty:
56
60
return 'You have nothing to send!' ;
61
+ case ContentValidationError .uploadInProgress:
62
+ return 'Please wait for the upload to complete.' ;
57
63
}
58
64
}
59
65
}
60
66
61
67
class ContentTextEditingController extends TextEditingController {
68
+ int _uploadCounter = 0 ;
69
+
70
+ final Map <int , ({String filename, String placeholder})> _uploads = {};
71
+
72
+ /// The cursor position, if selection is valid and collapsed, else text.length.
73
+ TextRange _cursorPositionOrEnd () {
74
+ final TextRange selection = value.selection;
75
+ final String text = value.text;
76
+ final int index = (selection.isValid && selection.end < text.length && selection.isCollapsed)
77
+ ? selection.end
78
+ : text.length;
79
+ return TextRange .collapsed (index);
80
+ }
81
+
82
+ /// Tells the controller that a file upload has started.
83
+ ///
84
+ /// Returns an int "tag" that should be passed to registerUploadEnd on the
85
+ /// upload's success or failure.
86
+ int registerUploadStart (String filename) {
87
+ final tag = _uploadCounter;
88
+ _uploadCounter += 1 ;
89
+ final placeholder = '[Uploading $filename ...]()\n\n ' ; // TODO(i18n)
90
+ _uploads[tag] = (filename: filename, placeholder: placeholder);
91
+ notifyListeners (); // _uploads change could affect validationErrors
92
+ value = value.replaced (_cursorPositionOrEnd (), placeholder);
93
+ return tag;
94
+ }
95
+
96
+ /// Tells the controller that a file upload has ended, with success or error.
97
+ ///
98
+ /// To indicate success, pass the URL to be used for the Markdown link.
99
+ /// Without that, failure is assumed.
100
+ void registerUploadEnd (int tag, Uri ? url) {
101
+ final val = _uploads[tag];
102
+ assert (val != null , 'registerUploadEnd called twice for same tag' );
103
+ final (filename: filename, placeholder: placeholder) = val! ;
104
+ final int startIndex = text.indexOf (placeholder);
105
+ final replacementRange = startIndex >= 0
106
+ ? TextRange (start: startIndex, end: startIndex + placeholder.length)
107
+ : _cursorPositionOrEnd ();
108
+
109
+ // TODO(i18n)
110
+ value = value.replaced (
111
+ replacementRange,
112
+ url == null
113
+ ? '[Failed to upload file: $filename ]()\n\n '
114
+ : '[$filename ](${url .toString ()})\n\n ' );
115
+ _uploads.remove (tag);
116
+ notifyListeners (); // _uploads change could affect validationErrors
117
+ }
118
+
62
119
String textNormalized () {
63
120
return text.trim ();
64
121
}
@@ -74,6 +131,9 @@ class ContentTextEditingController extends TextEditingController {
74
131
// be conservative and may cut the user off shorter than necessary.
75
132
if (normalized.length > kMaxMessageLengthCodePoints)
76
133
ContentValidationError .tooLong,
134
+
135
+ if (_uploads.isNotEmpty)
136
+ ContentValidationError .uploadInProgress,
77
137
];
78
138
}
79
139
}
@@ -257,6 +317,91 @@ class _StreamSendButtonState extends State<_StreamSendButton> {
257
317
}
258
318
}
259
319
320
+ class _AttachFileButton extends StatelessWidget {
321
+ const _AttachFileButton ({required this .contentController});
322
+
323
+ final ContentTextEditingController contentController;
324
+
325
+ _handlePress (BuildContext context) async {
326
+ FilePickerResult ? result;
327
+ try {
328
+ result = await FilePicker .platform.pickFiles (allowMultiple: true , withReadStream: true );
329
+ } catch (e) {
330
+ // TODO(i18n)
331
+ showErrorDialog (context: context, title: 'Error' , message: e.toString ());
332
+ return ;
333
+ }
334
+
335
+ if (result == null ) {
336
+ return ; // User cancelled; do nothing
337
+ }
338
+
339
+ if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
340
+ else {
341
+ return ;
342
+ }
343
+
344
+ final store = PerAccountStoreWidget .of (context);
345
+ final account = store.account;
346
+ final realmUrl = Uri .parse (account.realmUrl);
347
+
348
+ final List <PlatformFile > tooLargeFiles = [];
349
+ final List <PlatformFile > rightSizeFiles = [];
350
+
351
+ for (PlatformFile file in result.files) {
352
+ if ((file.size / pow (2 , 20 )) > store.maxFileUploadSizeMib) {
353
+ tooLargeFiles.add (file);
354
+ } else {
355
+ rightSizeFiles.add (file);
356
+ }
357
+ }
358
+
359
+
360
+ if (tooLargeFiles.isNotEmpty) {
361
+ final listMessage = tooLargeFiles
362
+ .map ((file) => '${file .name }: ${(file .size / pow (2 , 20 )).toStringAsFixed (1 )} MiB' )
363
+ .join ('\n ' );
364
+ showErrorDialog ( // TODO(i18n)
365
+ context: context,
366
+ title: 'File(s) too large' ,
367
+ message:
368
+ '${tooLargeFiles .length } file(s) are larger than the server\' s limit of ${store .maxFileUploadSizeMib } MiB and will not be uploaded:\n\n $listMessage ' );
369
+ }
370
+
371
+ final List <(int , PlatformFile )> uploadsInProgress = [];
372
+ for (final file in rightSizeFiles) {
373
+ final tag = contentController.registerUploadStart (file.name);
374
+ uploadsInProgress.add ((tag, file));
375
+ }
376
+
377
+ for (final (tag, file) in uploadsInProgress) {
378
+ final readStream = file.readStream;
379
+ final size = file.size;
380
+ final name = file.name;
381
+
382
+ // Will fail if we didn't pass `withReadStream: true` to pickFiles
383
+ assert (readStream != null , 'readStream missing' );
384
+ Uri ? url;
385
+ try {
386
+ final result = await uploadFile (store.connection, content: readStream! , length: size, filename: name);
387
+ url = realmUrl.resolve (result.uri);
388
+ } catch (e) {
389
+ if (! context.mounted) return ;
390
+ // TODO: Specifically handle `413 Payload Too Large`
391
+ // TODO: On API errors, quote `msg` from server, with "The server said:"
392
+ showErrorDialog (context: context, title: 'Failed to upload file: $name ' , message: e.toString ());
393
+ } finally {
394
+ contentController.registerUploadEnd (tag, url);
395
+ }
396
+ }
397
+ }
398
+
399
+ @override
400
+ Widget build (BuildContext context) {
401
+ return IconButton (icon: const Icon (Icons .attach_file), onPressed: () => _handlePress (context));
402
+ }
403
+ }
404
+
260
405
/// The compose box for writing a stream message.
261
406
class StreamComposeBox extends StatefulWidget {
262
407
const StreamComposeBox ({super .key});
@@ -310,18 +455,29 @@ class _StreamComposeBoxState extends State<StreamComposeBox> {
310
455
minimum: const EdgeInsets .fromLTRB (8 , 0 , 8 , 8 ),
311
456
child: Padding (
312
457
padding: const EdgeInsets .only (top: 8.0 ),
313
- child: Row (crossAxisAlignment: CrossAxisAlignment .end, children: [
314
- Expanded (
315
- child: Theme (
316
- data: inputThemeData,
317
- child: Column (
318
- children: [
319
- topicInput,
320
- const SizedBox (height: 8 ),
321
- _StreamContentInput (topicController: _topicController, controller: _contentController),
322
- ]))),
323
- const SizedBox (width: 8 ),
324
- _StreamSendButton (topicController: _topicController, contentController: _contentController),
325
- ]))));
458
+ child: Column (
459
+ children: [
460
+ Row (crossAxisAlignment: CrossAxisAlignment .end, children: [
461
+ Expanded (
462
+ child: Theme (
463
+ data: inputThemeData,
464
+ child: Column (
465
+ children: [
466
+ topicInput,
467
+ const SizedBox (height: 8 ),
468
+ _StreamContentInput (topicController: _topicController, controller: _contentController),
469
+ ]))),
470
+ const SizedBox (width: 8 ),
471
+ _StreamSendButton (topicController: _topicController, contentController: _contentController),
472
+ ]),
473
+ Theme (
474
+ data: themeData.copyWith (
475
+ iconTheme: themeData.iconTheme.copyWith (color: colorScheme.onSurfaceVariant)),
476
+ child: Row (
477
+ children: [
478
+ _AttachFileButton (contentController: _contentController),
479
+ ])),
480
+ ],
481
+ ))));
326
482
}
327
483
}
0 commit comments