1
+ import 'package:file_picker/file_picker.dart' ;
1
2
import 'package:flutter/material.dart' ;
2
3
import 'dialog.dart' ;
3
4
@@ -44,21 +45,83 @@ class TopicTextEditingController extends TextEditingController {
44
45
45
46
enum ContentValidationError {
46
47
empty,
47
- tooLong;
48
+ tooLong,
49
+ uploadInProgress;
48
50
49
- // Later: upload in progress; quote-and-reply in progress
51
+ // Later: quote-and-reply in progress
50
52
51
53
String message () {
52
54
switch (this ) {
53
55
case ContentValidationError .tooLong:
54
56
return "Message length shouldn't be greater than 10000 characters." ;
55
57
case ContentValidationError .empty:
56
58
return 'You have nothing to send!' ;
59
+ case ContentValidationError .uploadInProgress:
60
+ return 'Please wait for the upload to complete.' ;
57
61
}
58
62
}
59
63
}
60
64
61
65
class ContentTextEditingController extends TextEditingController {
66
+ int _nextUploadTag = 0 ;
67
+
68
+ final Map <int , ({String filename, String placeholder})> _uploads = {};
69
+
70
+ /// A probably reasonable place to insert Markdown, such as for a file upload.
71
+ ///
72
+ /// If [value.selection] is:
73
+ /// - valid and collapsed (i.e., it describes the caret position in the input,
74
+ /// which is focused), returns that.
75
+ /// - invalid (probably because the input is not focused), returns
76
+ /// a caret position at the end of the text.
77
+ /// - valid but not collapsed (it describes a range of selected text),
78
+ /// returns a caret position at the end of the selected range.
79
+ TextRange _insertionIndex () {
80
+ final TextRange selection = value.selection;
81
+ final String text = value.text;
82
+ return selection.isValid
83
+ ? (selection.isCollapsed
84
+ ? selection
85
+ : TextRange .collapsed (selection.end))
86
+ : TextRange .collapsed (text.length);
87
+ }
88
+
89
+ /// Tells the controller that a file upload has started.
90
+ ///
91
+ /// Returns an int "tag" that should be passed to registerUploadEnd on the
92
+ /// upload's success or failure.
93
+ int registerUploadStart (String filename) {
94
+ final tag = _nextUploadTag;
95
+ _nextUploadTag += 1 ;
96
+ final placeholder = '[Uploading $filename ...]()' ; // TODO(i18n)
97
+ _uploads[tag] = (filename: filename, placeholder: placeholder);
98
+ notifyListeners (); // _uploads change could affect validationErrors
99
+ value = value.replaced (_insertionIndex (), '$placeholder \n\n ' );
100
+ return tag;
101
+ }
102
+
103
+ /// Tells the controller that a file upload has ended, with success or error.
104
+ ///
105
+ /// To indicate success, pass the URL to be used for the Markdown link.
106
+ /// If `url` is null, failure is assumed.
107
+ void registerUploadEnd (int tag, Uri ? url) {
108
+ final val = _uploads[tag];
109
+ assert (val != null , 'registerUploadEnd called twice for same tag' );
110
+ final (: filename, : placeholder) = val! ;
111
+ final int startIndex = text.indexOf (placeholder);
112
+ final replacementRange = startIndex >= 0
113
+ ? TextRange (start: startIndex, end: startIndex + placeholder.length)
114
+ : _insertionIndex ();
115
+
116
+ value = value.replaced (
117
+ replacementRange,
118
+ url == null
119
+ ? '[Failed to upload file: $filename ]()' // TODO(i18n)
120
+ : '[$filename ](${url .toString ()})' );
121
+ _uploads.remove (tag);
122
+ notifyListeners (); // _uploads change could affect validationErrors
123
+ }
124
+
62
125
String textNormalized () {
63
126
return text.trim ();
64
127
}
@@ -74,6 +137,9 @@ class ContentTextEditingController extends TextEditingController {
74
137
// be conservative and may cut the user off shorter than necessary.
75
138
if (normalized.length > kMaxMessageLengthCodePoints)
76
139
ContentValidationError .tooLong,
140
+
141
+ if (_uploads.isNotEmpty)
142
+ ContentValidationError .uploadInProgress,
77
143
];
78
144
}
79
145
}
@@ -143,6 +209,88 @@ class _StreamContentInputState extends State<_StreamContentInput> {
143
209
}
144
210
}
145
211
212
+ class _AttachFileButton extends StatelessWidget {
213
+ const _AttachFileButton ({required this .contentController, required this .contentFocusNode});
214
+
215
+ final ContentTextEditingController contentController;
216
+ final FocusNode contentFocusNode;
217
+
218
+ _handlePress (BuildContext context) async {
219
+ FilePickerResult ? result;
220
+ try {
221
+ result = await FilePicker .platform.pickFiles (allowMultiple: true , withReadStream: true );
222
+ } catch (e) {
223
+ // TODO(i18n)
224
+ showErrorDialog (context: context, title: 'Error' , message: e.toString ());
225
+ return ;
226
+ }
227
+ if (result == null ) {
228
+ return ; // User cancelled; do nothing
229
+ }
230
+
231
+ if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
232
+ else {
233
+ return ;
234
+ }
235
+
236
+ final store = PerAccountStoreWidget .of (context);
237
+
238
+ final List <PlatformFile > tooLargeFiles = [];
239
+ final List <PlatformFile > rightSizeFiles = [];
240
+ for (PlatformFile file in result.files) {
241
+ if ((file.size / (1 << 20 )) > store.maxFileUploadSizeMib) {
242
+ tooLargeFiles.add (file);
243
+ } else {
244
+ rightSizeFiles.add (file);
245
+ }
246
+ }
247
+
248
+ if (tooLargeFiles.isNotEmpty) {
249
+ final listMessage = tooLargeFiles
250
+ .map ((file) => '${file .name }: ${(file .size / (1 << 20 )).toStringAsFixed (1 )} MiB' )
251
+ .join ('\n ' );
252
+ showErrorDialog ( // TODO(i18n)
253
+ context: context,
254
+ title: 'File(s) too large' ,
255
+ message:
256
+ '${tooLargeFiles .length } file(s) are larger than the server\' s limit of ${store .maxFileUploadSizeMib } MiB and will not be uploaded:\n\n $listMessage ' );
257
+ }
258
+
259
+ final List <(int , PlatformFile )> uploadsInProgress = [];
260
+ for (final file in rightSizeFiles) {
261
+ final tag = contentController.registerUploadStart (file.name);
262
+ uploadsInProgress.add ((tag, file));
263
+ }
264
+ if (! contentFocusNode.hasFocus) {
265
+ contentFocusNode.requestFocus ();
266
+ }
267
+
268
+ for (final (tag, file) in uploadsInProgress) {
269
+ final PlatformFile (: readStream, : size, : name) = file;
270
+
271
+ assert (readStream != null ); // We passed `withReadStream: true` to pickFiles.
272
+ Uri ? url;
273
+ try {
274
+ final result = await uploadFile (store.connection,
275
+ content: readStream! , length: size, filename: name);
276
+ url = Uri .parse (result.uri);
277
+ } catch (e) {
278
+ if (! context.mounted) return ;
279
+ // TODO(#37): Specifically handle `413 Payload Too Large`
280
+ // TODO(#37): On API errors, quote `msg` from server, with "The server said:"
281
+ showErrorDialog (context: context,
282
+ title: 'Failed to upload file: $name ' , message: e.toString ());
283
+ } finally {
284
+ contentController.registerUploadEnd (tag, url);
285
+ }
286
+ }
287
+ }
288
+
289
+ @override
290
+ Widget build (BuildContext context) {
291
+ return IconButton (icon: const Icon (Icons .attach_file), onPressed: () => _handlePress (context));
292
+ }
293
+ }
146
294
147
295
/// The send button for StreamComposeBox.
148
296
class _StreamSendButton extends StatefulWidget {
@@ -318,21 +466,31 @@ class _StreamComposeBoxState extends State<StreamComposeBox> {
318
466
minimum: const EdgeInsets .fromLTRB (8 , 0 , 8 , 8 ),
319
467
child: Padding (
320
468
padding: const EdgeInsets .only (top: 8.0 ),
321
- child: Row (crossAxisAlignment: CrossAxisAlignment .end, children: [
322
- Expanded (
323
- child: Theme (
324
- data: inputThemeData,
325
- child: Column (
326
- children: [
327
- topicInput,
328
- const SizedBox (height: 8 ),
329
- _StreamContentInput (
330
- topicController: _topicController,
331
- controller: _contentController,
332
- focusNode: _contentFocusNode),
333
- ]))),
334
- const SizedBox (width: 8 ),
335
- _StreamSendButton (topicController: _topicController, contentController: _contentController),
336
- ]))));
469
+ child: Column (
470
+ children: [
471
+ Row (crossAxisAlignment: CrossAxisAlignment .end, children: [
472
+ Expanded (
473
+ child: Theme (
474
+ data: inputThemeData,
475
+ child: Column (
476
+ children: [
477
+ topicInput,
478
+ const SizedBox (height: 8 ),
479
+ _StreamContentInput (
480
+ topicController: _topicController,
481
+ controller: _contentController,
482
+ focusNode: _contentFocusNode),
483
+ ]))),
484
+ const SizedBox (width: 8 ),
485
+ _StreamSendButton (topicController: _topicController, contentController: _contentController),
486
+ ]),
487
+ Theme (
488
+ data: themeData.copyWith (
489
+ iconTheme: themeData.iconTheme.copyWith (color: colorScheme.onSurfaceVariant)),
490
+ child: Row (
491
+ children: [
492
+ _AttachFileButton (contentController: _contentController, contentFocusNode: _contentFocusNode),
493
+ ])),
494
+ ]))));
337
495
}
338
496
}
0 commit comments