Skip to content

Commit 1927d23

Browse files
committed
wip compose_box: Integrate ComposeBox.
Signed-off-by: Zixuan James Li <[email protected]>
1 parent c39c8b7 commit 1927d23

File tree

5 files changed

+194
-5
lines changed

5 files changed

+194
-5
lines changed

lib/widgets/compose_box.dart

+57-5
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,9 @@ class _StreamContentInput extends StatefulWidget {
334334
class _StreamContentInputState extends State<_StreamContentInput> {
335335
late String _topicTextNormalized;
336336

337+
TopicNarrow get typingNarrow =>
338+
TopicNarrow(widget.narrow.streamId, _topicTextNormalized);
339+
337340
void _topicChanged() {
338341
setState(() {
339342
_topicTextNormalized = widget.topicController.textNormalized;
@@ -345,6 +348,8 @@ class _StreamContentInputState extends State<_StreamContentInput> {
345348
super.initState();
346349
_topicTextNormalized = widget.topicController.textNormalized;
347350
widget.topicController.addListener(_topicChanged);
351+
widget.controller.addListener(_contentChanged);
352+
widget.focusNode.addListener(_focusChanged);
348353
}
349354

350355
@override
@@ -359,9 +364,24 @@ class _StreamContentInputState extends State<_StreamContentInput> {
359364
@override
360365
void dispose() {
361366
widget.topicController.removeListener(_topicChanged);
367+
widget.controller.removeListener(_contentChanged);
368+
widget.focusNode.removeListener(_focusChanged);
362369
super.dispose();
363370
}
364371

372+
void _contentChanged() {
373+
final store = PerAccountStoreWidget.of(context);
374+
store.typingNotifier.handleTypingStatusUpdate(store,
375+
widget.controller.text.isNotEmpty ? typingNarrow : null);
376+
}
377+
378+
void _focusChanged() {
379+
if (widget.focusNode.hasFocus) return;
380+
381+
final store = PerAccountStoreWidget.of(context);
382+
store.typingNotifier.handleTypingStatusUpdate(store, null);
383+
}
384+
365385
@override
366386
Widget build(BuildContext context) {
367387
final store = PerAccountStoreWidget.of(context);
@@ -408,7 +428,7 @@ class _TopicInput extends StatelessWidget {
408428
}
409429
}
410430

411-
class _FixedDestinationContentInput extends StatelessWidget {
431+
class _FixedDestinationContentInput extends StatefulWidget {
412432
const _FixedDestinationContentInput({
413433
required this.narrow,
414434
required this.controller,
@@ -419,9 +439,41 @@ class _FixedDestinationContentInput extends StatelessWidget {
419439
final ComposeContentController controller;
420440
final FocusNode focusNode;
421441

442+
@override
443+
State<_FixedDestinationContentInput> createState() => _FixedDestinationContentInputState();
444+
}
445+
446+
class _FixedDestinationContentInputState extends State<_FixedDestinationContentInput> {
447+
@override
448+
void initState() {
449+
super.initState();
450+
widget.controller.addListener(_contentChanged);
451+
widget.focusNode.addListener(_focusChanged);
452+
}
453+
454+
@override
455+
void dispose() {
456+
widget.controller.removeListener(_contentChanged);
457+
widget.focusNode.removeListener(_focusChanged);
458+
super.dispose();
459+
}
460+
461+
void _contentChanged() {
462+
final store = PerAccountStoreWidget.of(context);
463+
store.typingNotifier.handleTypingStatusUpdate(store,
464+
widget.controller.text.isNotEmpty ? widget.narrow : null);
465+
}
466+
467+
void _focusChanged() {
468+
if (widget.focusNode.hasFocus) return;
469+
470+
final store = PerAccountStoreWidget.of(context);
471+
store.typingNotifier.handleTypingStatusUpdate(store, null);
472+
}
473+
422474
String _hintText(BuildContext context) {
423475
final zulipLocalizations = ZulipLocalizations.of(context);
424-
switch (narrow) {
476+
switch (widget.narrow) {
425477
case TopicNarrow(:final streamId, :final topic):
426478
final store = PerAccountStoreWidget.of(context);
427479
final streamName = store.streams[streamId]?.name
@@ -445,9 +497,9 @@ class _FixedDestinationContentInput extends StatelessWidget {
445497
@override
446498
Widget build(BuildContext context) {
447499
return _ContentInput(
448-
narrow: narrow,
449-
controller: controller,
450-
focusNode: focusNode,
500+
narrow: widget.narrow,
501+
controller: widget.controller,
502+
focusNode: widget.focusNode,
451503
hintText: _hintText(context));
452504
}
453505
}

test/widgets/action_sheet_test.dart

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:zulip/model/internal_link.dart';
1414
import 'package:zulip/model/localizations.dart';
1515
import 'package:zulip/model/narrow.dart';
1616
import 'package:zulip/model/store.dart';
17+
import 'package:zulip/model/typing_status.dart';
1718
import 'package:zulip/widgets/compose_box.dart';
1819
import 'package:zulip/widgets/content.dart';
1920
import 'package:zulip/widgets/icons.dart';
@@ -259,6 +260,8 @@ void main() {
259260
await tester.ensureVisible(find.byIcon(Icons.format_quote_outlined, skipOffstage: false));
260261
final quoteAndReplyButton = findQuoteAndReplyButton(tester);
261262
check(quoteAndReplyButton).isNotNull();
263+
DebugTypingNotifier.debugEnable = false;
264+
addTearDown(DebugTypingNotifier.debugReset);
262265
await tester.tap(find.byWidget(quoteAndReplyButton!));
263266
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
264267
}

test/widgets/autocomplete_test.dart

+7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:zulip/model/compose.dart';
88
import 'package:zulip/model/localizations.dart';
99
import 'package:zulip/model/narrow.dart';
1010
import 'package:zulip/model/store.dart';
11+
import 'package:zulip/model/typing_status.dart';
1112
import 'package:zulip/widgets/message_list.dart';
1213

1314
import '../api/fake_api.dart';
@@ -129,6 +130,9 @@ void main() {
129130
final composeInputFinder = await setupToComposeInput(tester, users: [user1, user2, user3]);
130131
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
131132

133+
DebugTypingNotifier.debugEnable = false;
134+
addTearDown(DebugTypingNotifier.debugReset);
135+
132136
// Options are filtered correctly for query
133137
// TODO(#226): Remove this extra edit when this bug is fixed.
134138
await tester.enterText(composeInputFinder, 'hello @user ');
@@ -180,6 +184,9 @@ void main() {
180184
final topicInputFinder = await setupToTopicInput(tester, topics: [topic1, topic2, topic3]);
181185
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
182186

187+
DebugTypingNotifier.debugEnable = false;
188+
addTearDown(DebugTypingNotifier.debugReset);
189+
183190
// Options are filtered correctly for query
184191
// TODO(#226): Remove this extra edit when this bug is fixed.
185192
await tester.enterText(topicInputFinder, 'Topic');

test/widgets/compose_box_test.dart

+124
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ import 'package:zulip/api/route/messages.dart';
1313
import 'package:zulip/model/localizations.dart';
1414
import 'package:zulip/model/narrow.dart';
1515
import 'package:zulip/model/store.dart';
16+
import 'package:zulip/model/typing_status.dart';
17+
import 'package:zulip/widgets/app.dart';
1618
import 'package:zulip/widgets/compose_box.dart';
19+
import 'package:zulip/widgets/page.dart';
1720

1821
import '../api/fake_api.dart';
1922
import '../example_data.dart' as eg;
2023
import '../flutter_checks.dart';
2124
import '../model/binding.dart';
2225
import '../model/test_store.dart';
26+
import '../model/typing_status_test.dart';
2327
import '../stdlib_checks.dart';
2428
import 'dialog_checks.dart';
2529
import 'test_app.dart';
@@ -53,6 +57,22 @@ void main() {
5357
return controllerKey;
5458
}
5559

60+
Future<void> prepareComposeBoxWithNavigation(WidgetTester tester,
61+
{required Narrow narrow}) async {
62+
addTearDown(testBinding.reset);
63+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
64+
65+
await tester.pumpWidget(const ZulipApp());
66+
await tester.pump();
67+
final navigator = await ZulipApp.navigator;
68+
navigator.push(MaterialAccountWidgetRoute(
69+
accountId: eg.selfAccount.id, page: ComposeBox(narrow: narrow)));
70+
await tester.pumpAndSettle();
71+
72+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
73+
connection = store.connection as FakeApiConnection;
74+
}
75+
5676
group('ComposeContentController', () {
5777
group('insertPadded', () {
5878
// Like `parseMarkedText` in test/model/autocomplete_test.dart,
@@ -155,6 +175,101 @@ void main() {
155175
});
156176
});
157177

178+
group('ComposeBox typing notification', () {
179+
void checkTypingRequest(TypingOp op, SendableNarrow narrow) =>
180+
checkSetTypingStatusRequests(connection, [(op, narrow)]);
181+
182+
final testCases = [
183+
const TopicNarrow(123, 'some topic'),
184+
DmNarrow.withUsers([eg.otherUser.userId], selfUserId: eg.selfUser.userId),
185+
];
186+
187+
final contentInputFinder = find.byWidgetPredicate(
188+
(widget) => widget is TextField && widget.controller is ComposeContentController);
189+
final topicInputFinder = find.byWidgetPredicate(
190+
(widget) => widget is TextField && widget.controller is ComposeTopicController);
191+
192+
for (final narrow in testCases) {
193+
testWidgets('smoke $narrow', (tester) async {
194+
await prepareComposeBox(tester, narrow: narrow);
195+
196+
connection.prepare(json: {});
197+
await tester.enterText(contentInputFinder, 'hello world');
198+
checkTypingRequest(TypingOp.start, narrow);
199+
200+
connection.prepare(json: {});
201+
tester.pumpAndSettle(store.typingNotifier.typingStoppedWaitPeriod);
202+
checkTypingRequest(TypingOp.stop, narrow);
203+
});
204+
205+
testWidgets('$narrow: clearing text', (tester) async {
206+
await prepareComposeBox(tester, narrow: narrow);
207+
208+
connection.prepare(json: {});
209+
await tester.enterText(contentInputFinder, 'hello world');
210+
checkTypingRequest(TypingOp.start, narrow);
211+
212+
connection.prepare(json: {});
213+
await tester.enterText(contentInputFinder, '');
214+
checkTypingRequest(TypingOp.stop, narrow);
215+
});
216+
217+
testWidgets('$narrow: unfocusing content input stops typing notification', (tester) async {
218+
await prepareComposeBoxWithNavigation(tester, narrow: narrow);
219+
220+
connection.prepare(json: {});
221+
await tester.enterText(contentInputFinder, 'hello world');
222+
checkTypingRequest(TypingOp.start, narrow);
223+
224+
connection.prepare(json: {});
225+
(await ZulipApp.navigator).pop();
226+
tester.pump(Duration.zero);
227+
checkTypingRequest(TypingOp.stop, narrow);
228+
});
229+
}
230+
231+
testWidgets('smoke ChannelNarrow', (tester) async {
232+
const narrow = ChannelNarrow(123);
233+
await prepareComposeBox(tester, narrow: narrow);
234+
235+
await tester.enterText(topicInputFinder, 'test topic');
236+
// Clean an irrelevant topic request.
237+
check(connection.takeRequests()).single
238+
..method.equals('GET')
239+
..url.path.equals('/api/v1/users/me/123/topics');
240+
241+
connection.prepare(json: {});
242+
final typingNarrow = TopicNarrow(narrow.streamId, 'test topic');
243+
await tester.enterText(contentInputFinder, 'hello world');
244+
checkTypingRequest(TypingOp.start, typingNarrow);
245+
246+
connection.prepare(json: {});
247+
tester.pumpAndSettle(store.typingNotifier.typingStoppedWaitPeriod);
248+
checkTypingRequest(TypingOp.stop, typingNarrow);
249+
});
250+
251+
testWidgets('unfocusing content input stops typing notification', (tester) async {
252+
const narrow = ChannelNarrow(123);
253+
await prepareComposeBox(tester, narrow: narrow);
254+
255+
await tester.enterText(topicInputFinder, 'topic');
256+
// Clean an irrelevant topic request.
257+
check(connection.takeRequests()).single
258+
..method.equals('GET')
259+
..url.path.equals('/api/v1/users/me/123/topics');
260+
261+
connection.prepare(json: {});
262+
await tester.enterText(contentInputFinder, 'hello world');
263+
checkTypingRequest(TypingOp.start, TopicNarrow(narrow.streamId, 'topic'));
264+
265+
connection.prepare(json: {});
266+
await tester.tap(topicInputFinder);
267+
checkTypingRequest(TypingOp.stop, TopicNarrow(narrow.streamId, 'topic'));
268+
// Wait for the idle timer to be cancelled.
269+
await tester.pump(Duration.zero);
270+
});
271+
});
272+
158273
group('ComposeBox textCapitalization', () {
159274
void checkComposeBoxTextFields(WidgetTester tester, {
160275
required GlobalKey<ComposeBoxController> controllerKey,
@@ -198,6 +313,9 @@ void main() {
198313
Future<void> setupAndTapSend(WidgetTester tester, {
199314
required void Function(int messageId) prepareResponse,
200315
}) async {
316+
DebugTypingNotifier.debugEnable = false;
317+
addTearDown(DebugTypingNotifier.debugReset);
318+
201319
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
202320
await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'));
203321

@@ -263,6 +381,9 @@ void main() {
263381

264382
group('attach from media library', () {
265383
testWidgets('success', (tester) async {
384+
DebugTypingNotifier.debugEnable = false;
385+
addTearDown(DebugTypingNotifier.debugReset);
386+
266387
final controllerKey = await prepareComposeBox(tester, narrow: ChannelNarrow(eg.stream().streamId));
267388
final composeBoxController = controllerKey.currentState!;
268389

@@ -319,6 +440,9 @@ void main() {
319440

320441
group('attach from camera', () {
321442
testWidgets('success', (tester) async {
443+
DebugTypingNotifier.debugEnable = false;
444+
addTearDown(DebugTypingNotifier.debugReset);
445+
322446
final controllerKey = await prepareComposeBox(tester, narrow: ChannelNarrow(eg.stream().streamId));
323447
final composeBoxController = controllerKey.currentState!;
324448

test/widgets/message_list_test.dart

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:zulip/api/route/messages.dart';
1414
import 'package:zulip/model/localizations.dart';
1515
import 'package:zulip/model/narrow.dart';
1616
import 'package:zulip/model/store.dart';
17+
import 'package:zulip/model/typing_status.dart';
1718
import 'package:zulip/widgets/autocomplete.dart';
1819
import 'package:zulip/widgets/content.dart';
1920
import 'package:zulip/widgets/emoji_reaction.dart';
@@ -51,12 +52,14 @@ void main() {
5152
List<Subscription>? subscriptions,
5253
UnreadMessagesSnapshot? unreadMsgs,
5354
}) async {
55+
addTearDown(DebugTypingNotifier.debugReset);
5456
addTearDown(testBinding.reset);
5557
streams ??= subscriptions ??= [eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId))];
5658
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
5759
streams: streams, subscriptions: subscriptions, unreadMsgs: unreadMsgs));
5860
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
5961
connection = store.connection as FakeApiConnection;
62+
DebugTypingNotifier.debugEnable = false;
6063

6164
// prepare message list data
6265
await store.addUser(eg.selfUser);

0 commit comments

Comments
 (0)