Skip to content

Commit 5bff7f4

Browse files
committed
compose: Prototype compose box, using Material TextField widget
1 parent f0b10c8 commit 5bff7f4

File tree

4 files changed

+356
-4
lines changed

4 files changed

+356
-4
lines changed

lib/model/store.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import 'dart:convert';
44

55
import 'package:flutter/foundation.dart';
6+
import 'package:zulip/api/route/messages.dart';
67

78
import '../api/core.dart';
89
import '../api/model/events.dart';
@@ -106,6 +107,11 @@ class PerAccountStore extends ChangeNotifier {
106107
}
107108
}
108109

110+
Future<void> sendStreamMessage({required String topic, required String content}) {
111+
// TODO implement outbox; see design at
112+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739
113+
return sendMessage(connection, topic: topic, content: content);
114+
}
109115
}
110116

111117
/// A scaffolding hack for while prototyping.

lib/widgets/app.dart

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22

3+
import 'compose_box.dart';
34
import 'message_list.dart';
45
import '../model/store.dart';
56

@@ -163,9 +164,6 @@ class MessageListPage extends StatelessWidget {
163164

164165
child: const Expanded(
165166
child: MessageList())),
166-
const SizedBox(
167-
height: 80,
168-
child: Center(
169-
child: Text("(Compose box goes here.)")))]))));
167+
const StreamComposeBox()]))));
170168
}
171169
}

lib/widgets/compose_box.dart

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import 'package:flutter/material.dart';
2+
import 'dialog.dart';
3+
4+
import 'app.dart';
5+
import '../api/route/messages.dart';
6+
7+
enum TopicValidationError {
8+
mandatoryButEmpty,
9+
tooLong;
10+
11+
String message() {
12+
switch (this) {
13+
case tooLong:
14+
return "Topic length shouldn't be greater than 60 characters.";
15+
case mandatoryButEmpty:
16+
return 'Topics are required in this organization.';
17+
}
18+
}
19+
}
20+
21+
class TopicTextEditingController extends TextEditingController {
22+
// TODO: subscribe to this value:
23+
// https://zulip.com/help/require-topics
24+
final mandatory = true;
25+
26+
String textNormalized() {
27+
String trimmed = text.trim();
28+
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
29+
}
30+
31+
List<TopicValidationError> validationErrors() {
32+
final normalized = textNormalized();
33+
return [
34+
if (mandatory && normalized == kNoTopicTopic)
35+
TopicValidationError.mandatoryButEmpty,
36+
if (normalized.length > kMaxTopicLength)
37+
TopicValidationError.tooLong,
38+
];
39+
}
40+
}
41+
42+
enum ContentValidationError {
43+
empty,
44+
tooLong;
45+
46+
// Later: upload in progress; quote-and-reply in progress
47+
48+
String message() {
49+
switch (this) {
50+
case ContentValidationError.tooLong:
51+
return "Message length shouldn't be greater than 10000 characters.";
52+
case ContentValidationError.empty:
53+
return 'You have nothing to send!';
54+
}
55+
}
56+
}
57+
58+
class ContentTextEditingController extends TextEditingController {
59+
String textNormalized() {
60+
return text.trim();
61+
}
62+
63+
List<ContentValidationError> validationErrors() {
64+
final normalized = textNormalized();
65+
return [
66+
if (normalized.isEmpty)
67+
ContentValidationError.empty,
68+
69+
// normalized.length is the number of UTF-16 code units, while the server
70+
// API expresses the max in Unicode code points. So this comparison will
71+
// be conservative and may cut the user off shorter than necessary.
72+
if (normalized.length > kMaxMessageLengthCodePoints)
73+
ContentValidationError.tooLong,
74+
];
75+
}
76+
}
77+
78+
/// The content input for StreamComposeBox.
79+
class _StreamContentInput extends StatefulWidget {
80+
const _StreamContentInput({required this.controller, required this.topicController});
81+
82+
final ContentTextEditingController controller;
83+
final TopicTextEditingController topicController;
84+
85+
@override
86+
State<_StreamContentInput> createState() => _StreamContentInputState();
87+
}
88+
89+
class _StreamContentInputState extends State<_StreamContentInput> {
90+
late String _topicTextNormalized;
91+
92+
_topicValueChanged() {
93+
setState(() {
94+
_topicTextNormalized = widget.topicController.textNormalized();
95+
});
96+
}
97+
98+
@override
99+
void initState() {
100+
super.initState();
101+
_topicTextNormalized = widget.topicController.textNormalized();
102+
widget.topicController.addListener(_topicValueChanged);
103+
}
104+
105+
@override
106+
void dispose() {
107+
widget.topicController.removeListener(_topicValueChanged);
108+
super.dispose();
109+
}
110+
111+
@override
112+
Widget build(BuildContext context) {
113+
ColorScheme colorScheme = Theme.of(context).colorScheme;
114+
115+
return ConstrainedBox(
116+
// TODO constrain height adaptively (i.e. not hard-coded 200)
117+
constraints: const BoxConstraints(maxHeight: 200),
118+
119+
child: TextField(
120+
controller: widget.controller,
121+
style: TextStyle(color: colorScheme.onSurface),
122+
decoration: InputDecoration(
123+
hintText: "Message #test here > $_topicTextNormalized",
124+
),
125+
maxLines: null,
126+
),
127+
);
128+
}
129+
}
130+
131+
132+
/// The send button for StreamComposeBox.
133+
class _StreamSendButton extends StatefulWidget {
134+
const _StreamSendButton({required this.topicController, required this.contentController});
135+
136+
final TopicTextEditingController topicController;
137+
final ContentTextEditingController contentController;
138+
139+
@override
140+
State<_StreamSendButton> createState() => _StreamSendButtonState();
141+
}
142+
143+
class _StreamSendButtonState extends State<_StreamSendButton> {
144+
late List<TopicValidationError> _topicValidationErrors;
145+
late List<ContentValidationError> _contentValidationErrors;
146+
147+
_topicValueChanged() {
148+
final oldIsEmpty = _topicValidationErrors.isEmpty;
149+
final newErrors = widget.topicController.validationErrors();
150+
final newIsEmpty = newErrors.isEmpty;
151+
_topicValidationErrors = newErrors;
152+
if (oldIsEmpty != newIsEmpty) {
153+
setState(() {
154+
// Update disabled/non-disabled state
155+
});
156+
}
157+
}
158+
159+
_contentValueChanged() {
160+
final oldIsEmpty = _contentValidationErrors.isEmpty;
161+
final newErrors = widget.contentController.validationErrors();
162+
final newIsEmpty = newErrors.isEmpty;
163+
_contentValidationErrors = newErrors;
164+
if (oldIsEmpty != newIsEmpty) {
165+
setState(() {
166+
// Update disabled/non-disabled state
167+
});
168+
}
169+
}
170+
171+
@override
172+
void initState() {
173+
super.initState();
174+
_topicValidationErrors = widget.topicController.validationErrors();
175+
_contentValidationErrors = widget.contentController.validationErrors();
176+
widget.topicController.addListener(_topicValueChanged);
177+
widget.contentController.addListener(_contentValueChanged);
178+
}
179+
180+
@override
181+
void dispose() {
182+
widget.topicController.removeListener(_topicValueChanged);
183+
widget.contentController.removeListener(_contentValueChanged);
184+
super.dispose();
185+
}
186+
187+
void _showSendFailedDialog(BuildContext context) {
188+
List<String> validationErrorMessages = [
189+
for (final error in _topicValidationErrors)
190+
error.message(),
191+
for (final error in _contentValidationErrors)
192+
error.message(),
193+
];
194+
195+
return showErrorDialog(
196+
context: context,
197+
title: 'Message not sent',
198+
message: validationErrorMessages.join('\n\n'));
199+
}
200+
201+
void _handleSendPressed (BuildContext context) {
202+
if (_topicValidationErrors.isNotEmpty || _contentValidationErrors.isNotEmpty) {
203+
_showSendFailedDialog(context);
204+
return;
205+
}
206+
207+
final store = PerAccountStoreWidget.of(context);
208+
store.sendStreamMessage(
209+
topic: widget.topicController.textNormalized(),
210+
content: widget.contentController.textNormalized(),
211+
);
212+
213+
widget.contentController.clear();
214+
}
215+
216+
@override
217+
Widget build(BuildContext context) {
218+
ColorScheme colorScheme = Theme.of(context).colorScheme;
219+
220+
bool disabled = _topicValidationErrors.isNotEmpty || _contentValidationErrors.isNotEmpty;
221+
222+
// Copy FilledButton defaults (_FilledButtonDefaultsM3.backgroundColor)
223+
final backgroundColor = disabled
224+
? colorScheme.onSurface.withOpacity(0.12)
225+
: colorScheme.primary;
226+
227+
// Copy FilledButton defaults (_FilledButtonDefaultsM3.foregroundColor)
228+
final foregroundColor = disabled
229+
? colorScheme.onSurface.withOpacity(0.38)
230+
: colorScheme.onPrimary;
231+
232+
return Ink(
233+
decoration: BoxDecoration(
234+
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
235+
color: backgroundColor,
236+
),
237+
child: IconButton(
238+
// Empirically, match the height of the content input. Ideally, this
239+
// would be dynamic and respond to the actual height of the content
240+
// input. Zeroing the padding lets the constraints take over.
241+
constraints: const BoxConstraints(minWidth: 35, minHeight: 35),
242+
padding: const EdgeInsets.all(0),
243+
244+
color: foregroundColor,
245+
icon: const Icon(Icons.send),
246+
onPressed: () => _handleSendPressed(context),
247+
),
248+
);
249+
}
250+
}
251+
252+
/// The compose box for writing a stream message.
253+
class StreamComposeBox extends StatefulWidget {
254+
const StreamComposeBox({super.key});
255+
256+
@override
257+
State<StreamComposeBox> createState() => _StreamComposeBoxState();
258+
}
259+
260+
class _StreamComposeBoxState extends State<StreamComposeBox> {
261+
final _topicController = TopicTextEditingController();
262+
final _contentController = ContentTextEditingController();
263+
264+
@override
265+
void dispose() {
266+
_topicController.dispose();
267+
_contentController.dispose();
268+
super.dispose();
269+
}
270+
271+
@override
272+
Widget build(BuildContext context) {
273+
ThemeData themeData = Theme.of(context);
274+
ColorScheme colorScheme = themeData.colorScheme;
275+
276+
final inputThemeData = themeData.copyWith(
277+
inputDecorationTheme: InputDecorationTheme(
278+
// Both [contentPadding] and [isDense] combine to make the layout compact.
279+
isDense: true,
280+
contentPadding: const EdgeInsets.symmetric(
281+
horizontal: 12.0, vertical: 8.0),
282+
283+
border: const OutlineInputBorder(
284+
borderRadius: BorderRadius.all(Radius.circular(4.0)),
285+
borderSide: BorderSide.none,
286+
),
287+
288+
filled: true,
289+
fillColor: colorScheme.surface,
290+
),
291+
);
292+
293+
final topicInput = TextField(
294+
controller: _topicController,
295+
style: TextStyle(color: colorScheme.onSurface),
296+
decoration: const InputDecoration(hintText: 'Topic'),
297+
);
298+
299+
return Material(
300+
color: colorScheme.surfaceVariant,
301+
child: SafeArea(
302+
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
303+
child: Padding(
304+
padding: const EdgeInsets.only(top: 8.0),
305+
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
306+
Expanded(
307+
child: Theme(
308+
data: inputThemeData,
309+
child: Column(
310+
children: [
311+
topicInput,
312+
const SizedBox(height: 8),
313+
_StreamContentInput(topicController: _topicController, controller: _contentController),
314+
]))),
315+
const SizedBox(width: 8),
316+
_StreamSendButton(topicController: _topicController, contentController: _contentController),
317+
]))));
318+
}
319+
}

lib/widgets/dialog.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'package:flutter/material.dart';
2+
3+
Widget _dialogActionText(String text) {
4+
return Text(
5+
text,
6+
7+
// As suggested by
8+
// https://api.flutter.dev/flutter/material/AlertDialog/actions.html :
9+
// > It is recommended to set the Text.textAlign to TextAlign.end
10+
// > for the Text within the TextButton, so that buttons whose
11+
// > labels wrap to an extra line align with the overall
12+
// > OverflowBar's alignment within the dialog.
13+
textAlign: TextAlign.end,
14+
);
15+
}
16+
17+
// TODO(i18n): title, message, and action-button text
18+
void showErrorDialog({required BuildContext context, required String title, String? message}) {
19+
showDialog(
20+
context: context,
21+
builder: (BuildContext context) => AlertDialog(
22+
title: Text(title),
23+
content: message != null ? SingleChildScrollView(child: Text(message)) : null,
24+
actions: [
25+
TextButton(
26+
onPressed: () => Navigator.pop(context),
27+
child: _dialogActionText('OK')),
28+
]));
29+
}

0 commit comments

Comments
 (0)