Skip to content

Commit 4980bc8

Browse files
committed
compose: Prototype compose box, using Material TextField widget
1 parent 6b4a83f commit 4980bc8

File tree

5 files changed

+328
-4
lines changed

5 files changed

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

lib/widgets/dialog.dart

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

lib/widgets/message_list.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ class _MessageListState extends State<MessageList> {
8181
final length = model!.messages.length;
8282
assert(model!.contents.length == length);
8383
return StickyHeaderListView.builder(
84+
// Dismiss compose-box keyboard when dragging up or down.
85+
// TODO(?) refine with custom implementation, e.g.:
86+
// - Only dismiss when scrolling up to older messages
87+
// - Show keyboard (focus compose box) on overscroll up to new
88+
// messages (OverscrollNotification)
89+
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
90+
8491
itemCount: length,
8592
// Setting reverse: true means the scroll starts at the bottom.
8693
// Flipping the indexes (in itemBuilder) means the start/bottom

0 commit comments

Comments
 (0)