Skip to content

Commit cb66c85

Browse files
committed
compose: Prototype compose box, using Material TextField widget
Add a prototype compose box - that can compose a stream message but not a PM - that sends to stream ID 7, which on CZO is "test here" - that's missing the native iOS text-input UI features, like scanning text with the camera (we want to write a thin wrapper around UITextField and UITextView for that). - that doesn't have autocomplete - that doesn't have quote-and-reply, attach-a-file, etc. - that doesn't send typing notifications - (etc.; this is a prototype :-) )
1 parent 2efb06c commit cb66c85

File tree

6 files changed

+353
-3
lines changed

6 files changed

+353
-3
lines changed

lib/api/route/messages.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,52 @@ class GetMessagesResult {
4646

4747
Map<String, dynamic> toJson() => _$GetMessagesResultToJson(this);
4848
}
49+
50+
// https://zulip.com/api/send-message#parameter-topic
51+
const int kMaxTopicLength = 60;
52+
53+
// https://zulip.com/api/send-message#parameter-content
54+
// TODO: might be wrong:
55+
// https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/max.20message.20size/near/1505518
56+
const int kMaxMessageLengthBytes = 10000;
57+
58+
/// The topic servers understand to mean "there is no topic".
59+
///
60+
/// This should match
61+
/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940
62+
/// or similar logic at the latest `main`.
63+
// This is hardcoded in the server, and therefore untranslated; that's
64+
// zulip/zulip#3639.
65+
const String kNoTopicTopic = '(no topic)';
66+
67+
/// https://zulip.com/api/send-message
68+
// TODO currently only handles stream messages; fix
69+
Future<SendMessageResult> sendMessage(
70+
ApiConnection connection, {
71+
required String content,
72+
required String topic,
73+
}) async {
74+
final data = await connection.post('messages', {
75+
'type': RawParameter('stream'), // TODO parametrize
76+
'to': 7, // TODO parametrize; this is `#test here`
77+
'topic': RawParameter(topic),
78+
'content': RawParameter(content),
79+
});
80+
return SendMessageResult.fromJson(jsonDecode(data));
81+
}
82+
83+
@JsonSerializable()
84+
class SendMessageResult {
85+
final int id;
86+
final String? deliver_at;
87+
88+
SendMessageResult({
89+
required this.id,
90+
this.deliver_at,
91+
});
92+
93+
factory SendMessageResult.fromJson(Map<String, dynamic> json) =>
94+
_$SendMessageResultFromJson(json);
95+
96+
Map<String, dynamic> toJson() => _$SendMessageResultToJson(this);
97+
}

lib/api/route/messages.g.dart

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/interactions/showErrorDialog.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(BuildContext context, 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/app.dart

Lines changed: 2 additions & 3 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

@@ -166,9 +167,7 @@ class MessageListPage extends StatelessWidget {
166167
removeBottom: true,
167168

168169
child: const Expanded(child: MessageList())),
169-
const SizedBox(
170-
height: 80,
171-
child: Center(child: Text("(Compose box goes here.)"))),
170+
const StreamComposeBox(),
172171
])));
173172
}
174173
}

lib/widgets/compose_box.dart

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import 'dart:convert';
2+
import 'package:flutter/material.dart';
3+
import 'package:zulip/interactions/showErrorDialog.dart';
4+
5+
import 'app.dart';
6+
import '../api/route/messages.dart';
7+
8+
enum TopicValidationError { mandatoryButEmpty, tooLong }
9+
10+
class TopicTextEditingController extends TextEditingController {
11+
// Mimic TextEditingController's non-default constructors, which are not
12+
// inherited. (Is there a more transparent way to do this?)
13+
TopicTextEditingController({ String? text }) : super(text: text);
14+
TopicTextEditingController.fromValue(TextEditingValue? value) : super.fromValue(value);
15+
16+
// TODO: subscribe to this value:
17+
// https://zulip.com/help/require-topics
18+
final mandatory = true;
19+
20+
String get textNormalized {
21+
String trimmed = text.trim();
22+
return trimmed.isEmpty ? kNoTopicTopic : trimmed;
23+
}
24+
25+
Set<TopicValidationError> get validationErrors {
26+
Set<TopicValidationError> result = Set.identity();
27+
if (mandatory && textNormalized == kNoTopicTopic) {
28+
result.add(TopicValidationError.mandatoryButEmpty);
29+
}
30+
if (textNormalized.length > kMaxTopicLength) {
31+
result.add(TopicValidationError.tooLong);
32+
}
33+
return result;
34+
}
35+
}
36+
37+
enum ContentValidationError {
38+
empty,
39+
tooLong,
40+
// TODO: upload in progress
41+
// TODO: quote-and-reply in progress
42+
}
43+
44+
class ContentTextEditingController extends TextEditingController {
45+
// Mimic TextEditingController's non-default constructors, which are not
46+
// inherited. (Is there a more transparent way to do this?)
47+
ContentTextEditingController({ String? text }) : super(text: text);
48+
ContentTextEditingController.fromValue(TextEditingValue? value) : super.fromValue(value);
49+
50+
String get textNormalized {
51+
return text.trim();
52+
}
53+
54+
Set<ContentValidationError> get validationErrors {
55+
Set<ContentValidationError> result = Set.identity();
56+
if (textNormalized.isEmpty) {
57+
result.add(ContentValidationError.empty);
58+
}
59+
if (utf8.encode(textNormalized).length > kMaxMessageLengthBytes) {
60+
result.add(ContentValidationError.tooLong);
61+
}
62+
return result;
63+
}
64+
}
65+
66+
/// The compose box for writing a stream message.
67+
class StreamComposeBox extends StatefulWidget {
68+
const StreamComposeBox({Key? key}) : super(key: key);
69+
70+
@override
71+
State<StreamComposeBox> createState() => _StreamComposeBoxState();
72+
}
73+
74+
class _StreamComposeBoxState extends State<StreamComposeBox> {
75+
final _topicController = TopicTextEditingController();
76+
final _contentController = ContentTextEditingController();
77+
78+
@override
79+
void dispose() {
80+
_topicController.dispose();
81+
_contentController.dispose();
82+
super.dispose();
83+
}
84+
85+
void _topicValueChanged() {
86+
setState(() {
87+
// The actual state lives in _topicController. This method was called
88+
// because that just changed.
89+
});
90+
}
91+
92+
void _messageValueChanged() {
93+
setState(() {
94+
// The actual state lives in _contentController. This method was called
95+
// because that just changed.
96+
});
97+
}
98+
99+
@override
100+
void initState() {
101+
super.initState();
102+
_topicController.addListener(_topicValueChanged);
103+
_contentController.addListener(_messageValueChanged);
104+
}
105+
106+
@override
107+
Widget build(BuildContext context) {
108+
ThemeData themeData = Theme.of(context);
109+
ColorScheme colorScheme = themeData.colorScheme;
110+
111+
bool submitButtonDisabled =
112+
_topicController.validationErrors.isNotEmpty
113+
|| _contentController.validationErrors.isNotEmpty;
114+
115+
return Material(
116+
color: colorScheme.surfaceVariant,
117+
118+
// Not sure what to choose here; 4 is AppBar's default.
119+
elevation: 4,
120+
121+
child: SafeArea(
122+
// A non-ancestor (the app bar) pads the top inset. But no
123+
// need to prevent extra padding with `top: false`, because
124+
// Scaffold, which knows about the app bar, sets `body`'s
125+
// ambient `MediaQueryData.padding` to have `top: 0`:
126+
// https://github.com/flutter/flutter/blob/3.7.0-1.2.pre/packages/flutter/lib/src/material/scaffold.dart#L2778
127+
128+
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
129+
child: Padding(
130+
padding: const EdgeInsets.only(top: 8.0),
131+
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
132+
Expanded(
133+
child: Theme(
134+
data: themeData.copyWith(
135+
inputDecorationTheme: InputDecorationTheme(
136+
isDense: true,
137+
138+
// Override the default, which even with isDense is
139+
// `EdgeInsets.fromLTRB(12.0, 20.0, 12.0, 12.0)`; see
140+
// https://github.com/flutter/flutter/blob/3.7.0-1.2.pre/packages/flutter/lib/src/material/input_decorator.dart#L2360
141+
// We'd like to be even denser.
142+
contentPadding: const EdgeInsets.symmetric(
143+
horizontal: 12.0, vertical: 8.0),
144+
145+
border: const OutlineInputBorder(
146+
borderRadius: BorderRadius.all(Radius.circular(4.0)),
147+
148+
// Opt out of special formatting for various
149+
// `MaterialState`s (focused, etc.), to match RN app
150+
borderSide: BorderSide.none,
151+
),
152+
// InputBorder.none,
153+
154+
filled: true,
155+
fillColor: colorScheme.surface,
156+
),
157+
),
158+
child: Column(
159+
children: [
160+
Container(
161+
margin: const EdgeInsets.only(bottom: 8),
162+
child: TextField(
163+
controller: _topicController,
164+
style: TextStyle(color: colorScheme.onSurface),
165+
166+
// Extends InputDecorationTheme above
167+
decoration: const InputDecoration(hintText: 'Topic'),
168+
),
169+
),
170+
ConstrainedBox(
171+
// TODO constrain height adaptively (i.e. not hard-coded 200)
172+
constraints: const BoxConstraints(maxHeight: 200),
173+
174+
child: TextField(
175+
controller: _contentController,
176+
style: TextStyle(color: colorScheme.onSurface),
177+
178+
// Extends InputDecorationTheme above
179+
decoration: InputDecoration(
180+
hintText: "Message #test here > ${_topicController.textNormalized}",
181+
),
182+
183+
maxLines: null,
184+
),
185+
186+
),
187+
],
188+
)),
189+
),
190+
Container(
191+
margin: const EdgeInsets.only(left: 8),
192+
child: Ink(
193+
decoration: BoxDecoration(
194+
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
195+
color: submitButtonDisabled ? themeData.disabledColor : colorScheme.primary,
196+
),
197+
child: IconButton(
198+
visualDensity: const VisualDensity(
199+
// …i.e., *most* dense/compact (yep):
200+
// https://api.flutter.dev/flutter/material/IconButton/visualDensity.html
201+
horizontal: VisualDensity.minimumDensity,
202+
vertical: VisualDensity.minimumDensity,
203+
),
204+
color: submitButtonDisabled ? themeData.disabledColor : colorScheme.onPrimary,
205+
icon: const Icon(Icons.send),
206+
onPressed: () {
207+
if (submitButtonDisabled) {
208+
showErrorDialog(
209+
context,
210+
'Message not sent',
211+
[
212+
..._topicController.validationErrors.map((error) {
213+
switch (error) {
214+
case TopicValidationError.tooLong:
215+
return "Topic length shouldn't be greater than 60 characters.";
216+
case TopicValidationError.mandatoryButEmpty:
217+
return 'Topics are required in this organization.';
218+
}
219+
}),
220+
..._contentController.validationErrors.map((error) {
221+
switch (error) {
222+
case ContentValidationError.tooLong:
223+
// TODO: UI text copied from web; is
224+
// it right? Discussion:
225+
// https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/max.20message.20size/near/1505518
226+
return "Message length shouldn't be greater than 10000 characters.";
227+
case ContentValidationError.empty:
228+
return 'You have nothing to send!';
229+
}
230+
}),
231+
].join('\n\n'));
232+
return;
233+
}
234+
235+
// TODO implement outbox; see design at
236+
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739
237+
sendMessage(
238+
PerAccountStoreWidget.of(context).connection,
239+
topic: _topicController.textNormalized,
240+
content: _contentController.textNormalized,
241+
);
242+
243+
_contentController.clear();
244+
},
245+
),
246+
),
247+
)
248+
]),
249+
)),
250+
);
251+
}
252+
}

lib/widgets/message_list.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ class _MessageListState extends State<MessageList> {
8989
// TODO handle scroll starting at first unread, or link anchor
9090
// TODO on new message when scrolled up, anchor scroll to what's in view
9191
reverse: true,
92+
// Dismiss compose-box keyboard when dragging up or down.
93+
// TODO(?) refine with custom implementation, e.g.:
94+
// - Only dismiss when scrolling up to older messages
95+
// - Show keyboard (focus compose box) on overscroll up to new
96+
// messages (OverscrollNotification)
97+
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
9298
itemBuilder: (context, i) => MessageItem(
9399
trailing: i == 0 ? null : const SizedBox(height: 11),
94100
message: model!.messages[length - 1 - i],

0 commit comments

Comments
 (0)