Skip to content

Commit 72186a3

Browse files
committed
compose: Prototype upload-file UI
Fixes: #57
1 parent 838dcc8 commit 72186a3

File tree

1 file changed

+176
-18
lines changed

1 file changed

+176
-18
lines changed

lib/widgets/compose_box.dart

Lines changed: 176 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:file_picker/file_picker.dart';
12
import 'package:flutter/material.dart';
23
import 'dialog.dart';
34

@@ -44,21 +45,83 @@ class TopicTextEditingController extends TextEditingController {
4445

4546
enum ContentValidationError {
4647
empty,
47-
tooLong;
48+
tooLong,
49+
uploadInProgress;
4850

49-
// Later: upload in progress; quote-and-reply in progress
51+
// Later: quote-and-reply in progress
5052

5153
String message() {
5254
switch (this) {
5355
case ContentValidationError.tooLong:
5456
return "Message length shouldn't be greater than 10000 characters.";
5557
case ContentValidationError.empty:
5658
return 'You have nothing to send!';
59+
case ContentValidationError.uploadInProgress:
60+
return 'Please wait for the upload to complete.';
5761
}
5862
}
5963
}
6064

6165
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+
62125
String textNormalized() {
63126
return text.trim();
64127
}
@@ -74,6 +137,9 @@ class ContentTextEditingController extends TextEditingController {
74137
// be conservative and may cut the user off shorter than necessary.
75138
if (normalized.length > kMaxMessageLengthCodePoints)
76139
ContentValidationError.tooLong,
140+
141+
if (_uploads.isNotEmpty)
142+
ContentValidationError.uploadInProgress,
77143
];
78144
}
79145
}
@@ -143,6 +209,88 @@ class _StreamContentInputState extends State<_StreamContentInput> {
143209
}
144210
}
145211

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+
}
146294

147295
/// The send button for StreamComposeBox.
148296
class _StreamSendButton extends StatefulWidget {
@@ -318,21 +466,31 @@ class _StreamComposeBoxState extends State<StreamComposeBox> {
318466
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
319467
child: Padding(
320468
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+
]))));
337495
}
338496
}

0 commit comments

Comments
 (0)