Skip to content

Commit 94d445c

Browse files
committed
compose: Prototype upload-file UI
Fixes: zulip#57
1 parent 1926c24 commit 94d445c

File tree

1 file changed

+171
-15
lines changed

1 file changed

+171
-15
lines changed

lib/widgets/compose_box.dart

Lines changed: 171 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'dart:math';
2+
3+
import 'package:file_picker/file_picker.dart';
14
import 'package:flutter/material.dart';
25
import 'dialog.dart';
36

@@ -44,21 +47,75 @@ class TopicTextEditingController extends TextEditingController {
4447

4548
enum ContentValidationError {
4649
empty,
47-
tooLong;
50+
tooLong,
51+
uploadInProgress;
4852

49-
// Later: upload in progress; quote-and-reply in progress
53+
// Later: quote-and-reply in progress
5054

5155
String message() {
5256
switch (this) {
5357
case ContentValidationError.tooLong:
5458
return "Message length shouldn't be greater than 10000 characters.";
5559
case ContentValidationError.empty:
5660
return 'You have nothing to send!';
61+
case ContentValidationError.uploadInProgress:
62+
return 'Please wait for the upload to complete.';
5763
}
5864
}
5965
}
6066

6167
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+
62119
String textNormalized() {
63120
return text.trim();
64121
}
@@ -74,6 +131,9 @@ class ContentTextEditingController extends TextEditingController {
74131
// be conservative and may cut the user off shorter than necessary.
75132
if (normalized.length > kMaxMessageLengthCodePoints)
76133
ContentValidationError.tooLong,
134+
135+
if (_uploads.isNotEmpty)
136+
ContentValidationError.uploadInProgress,
77137
];
78138
}
79139
}
@@ -257,6 +317,91 @@ class _StreamSendButtonState extends State<_StreamSendButton> {
257317
}
258318
}
259319

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+
260405
/// The compose box for writing a stream message.
261406
class StreamComposeBox extends StatefulWidget {
262407
const StreamComposeBox({super.key});
@@ -310,18 +455,29 @@ class _StreamComposeBoxState extends State<StreamComposeBox> {
310455
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
311456
child: Padding(
312457
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+
))));
326482
}
327483
}

0 commit comments

Comments
 (0)