Skip to content

Commit a3a2e97

Browse files
committed
compose: Track user interaction with inputs statefully
1 parent f3b2c4a commit a3a2e97

File tree

2 files changed

+130
-8
lines changed

2 files changed

+130
-8
lines changed

lib/widgets/compose_box.dart

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -576,16 +576,26 @@ class _StreamContentInputState extends State<_StreamContentInput> {
576576
});
577577
}
578578

579+
void _hasChosenTopicChanged() {
580+
setState(() {
581+
// The relevant state lives on widget.controller.hasChosenTopic itself.
582+
});
583+
}
584+
579585
void _contentFocusChanged() {
580586
setState(() {
581587
// The relevant state lives on widget.controller.contentFocusNode itself.
582588
});
589+
if (widget.controller.contentFocusNode.hasFocus){
590+
widget.controller.hasChosenTopic.value = true;
591+
}
583592
}
584593

585594
@override
586595
void initState() {
587596
super.initState();
588597
widget.controller.topic.addListener(_topicChanged);
598+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
589599
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
590600
}
591601

@@ -596,6 +606,10 @@ class _StreamContentInputState extends State<_StreamContentInput> {
596606
oldWidget.controller.topic.removeListener(_topicChanged);
597607
widget.controller.topic.addListener(_topicChanged);
598608
}
609+
if (widget.controller.hasChosenTopic != oldWidget.controller.hasChosenTopic) {
610+
oldWidget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
611+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
612+
}
599613
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
600614
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
601615
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
@@ -605,6 +619,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
605619
@override
606620
void dispose() {
607621
widget.controller.topic.removeListener(_topicChanged);
622+
widget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
608623
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
609624
super.dispose();
610625
}
@@ -616,7 +631,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
616631
// The chosen topic can't be sent to, so don't show it.
617632
return null;
618633
}
619-
if (!widget.controller.contentFocusNode.hasFocus) {
634+
if (!widget.controller.hasChosenTopic.value) {
620635
// Do not fall back to a vacuous topic unless the user explicitly chooses
621636
// to do so (by skipping topic input and moving focus to content input),
622637
// so that the user is not encouraged to use vacuous topic when they
@@ -656,12 +671,47 @@ class _StreamContentInputState extends State<_StreamContentInput> {
656671
}
657672
}
658673

659-
class _TopicInput extends StatelessWidget {
674+
class _TopicInput extends StatefulWidget {
660675
const _TopicInput({required this.streamId, required this.controller});
661676

662677
final int streamId;
663678
final StreamComposeBoxController controller;
664679

680+
@override
681+
State<_TopicInput> createState() => _TopicInputState();
682+
}
683+
684+
class _TopicInputState extends State<_TopicInput> {
685+
@override
686+
void initState() {
687+
super.initState();
688+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
689+
}
690+
691+
@override
692+
void didUpdateWidget(covariant _TopicInput oldWidget) {
693+
super.didUpdateWidget(oldWidget);
694+
if (widget.controller.topicFocusNode != oldWidget.controller.topicFocusNode) {
695+
oldWidget.controller.topicFocusNode.removeListener(_topicFocusChanged);
696+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
697+
}
698+
}
699+
700+
@override
701+
void dispose() {
702+
widget.controller.topicFocusNode.removeListener(_topicFocusChanged);
703+
super.dispose();
704+
}
705+
706+
void _topicFocusChanged() {
707+
setState(() {
708+
// The relevant state lives on widget.controller.topicFocusNode itself.
709+
});
710+
if (widget.controller.topicFocusNode.hasFocus) {
711+
widget.controller.hasChosenTopic.value = false;
712+
}
713+
}
714+
665715
@override
666716
Widget build(BuildContext context) {
667717
final zulipLocalizations = ZulipLocalizations.of(context);
@@ -673,18 +723,18 @@ class _TopicInput extends StatelessWidget {
673723
).merge(weightVariableTextStyle(context, wght: 600));
674724

675725
return TopicAutocomplete(
676-
streamId: streamId,
677-
controller: controller.topic,
678-
focusNode: controller.topicFocusNode,
679-
contentFocusNode: controller.contentFocusNode,
726+
streamId: widget.streamId,
727+
controller: widget.controller.topic,
728+
focusNode: widget.controller.topicFocusNode,
729+
contentFocusNode: widget.controller.contentFocusNode,
680730
fieldViewBuilder: (context) => Container(
681731
padding: const EdgeInsets.only(top: 10, bottom: 9),
682732
decoration: BoxDecoration(border: Border(bottom: BorderSide(
683733
width: 1,
684734
color: designVariables.foreground.withFadedAlpha(0.2)))),
685735
child: TextField(
686-
controller: controller.topic,
687-
focusNode: controller.topicFocusNode,
736+
controller: widget.controller.topic,
737+
focusNode: widget.controller.topicFocusNode,
688738
textInputAction: TextInputAction.next,
689739
style: topicTextStyle,
690740
decoration: InputDecoration(
@@ -1368,10 +1418,18 @@ class StreamComposeBoxController extends ComposeBoxController {
13681418
final ComposeTopicController topic;
13691419
final topicFocusNode = FocusNode();
13701420

1421+
/// Whether the user has made up their mind choosing a topic.
1422+
///
1423+
/// Empirically, this should be set to `false` whenever the user focuses on
1424+
/// the topic input, and set to `true` whenever the user focuses on the
1425+
/// content input.
1426+
ValueNotifier<bool> hasChosenTopic = ValueNotifier(false);
1427+
13711428
@override
13721429
void dispose() {
13731430
topic.dispose();
13741431
topicFocusNode.dispose();
1432+
hasChosenTopic.dispose();
13751433
super.dispose();
13761434
}
13771435
}

test/widgets/compose_box_test.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,37 @@ void main() {
390390
contentHintText: 'Message #${channel.name}');
391391
});
392392

393+
testWidgets('with empty topic, topic input has focus, then content input gains focus', (tester) async {
394+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
395+
await enterTopic(tester, narrow: narrow, topic: '');
396+
await tester.pump();
397+
checkComposeBoxHintTexts(tester,
398+
topicHintText: 'Topic',
399+
contentHintText: 'Message #${channel.name}');
400+
401+
await enterContent(tester, '');
402+
await tester.pump();
403+
checkComposeBoxHintTexts(tester,
404+
topicHintText: 'Topic',
405+
contentHintText: 'Message #${channel.name} > '
406+
'${eg.defaultRealmEmptyTopicDisplayName}');
407+
});
408+
409+
testWidgets('with empty topic, topic input has focus, then loses it', (tester) async {
410+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
411+
await enterTopic(tester, narrow: narrow, topic: '');
412+
await tester.pump();
413+
checkComposeBoxHintTexts(tester,
414+
topicHintText: 'Topic',
415+
contentHintText: 'Message #${channel.name}');
416+
417+
FocusManager.instance.primaryFocus!.unfocus();
418+
await tester.pump();
419+
checkComposeBoxHintTexts(tester,
420+
topicHintText: 'Topic',
421+
contentHintText: 'Message #${channel.name}');
422+
});
423+
393424
testWidgets('with empty topic, content input has focus', (tester) async {
394425
await prepare(tester, narrow: narrow, mandatoryTopics: false);
395426
await enterContent(tester, '');
@@ -410,6 +441,39 @@ void main() {
410441
contentHintText: 'Message #${channel.name} > (no topic)');
411442
});
412443

444+
testWidgets('with empty topic, content input has focus, then topic input gains focus', (tester) async {
445+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
446+
await enterContent(tester, '');
447+
await tester.pump();
448+
checkComposeBoxHintTexts(tester,
449+
topicHintText: 'Topic',
450+
contentHintText: 'Message #${channel.name} > '
451+
'${eg.defaultRealmEmptyTopicDisplayName}');
452+
453+
await enterTopic(tester, narrow: narrow, topic: '');
454+
await tester.pump();
455+
checkComposeBoxHintTexts(tester,
456+
topicHintText: 'Topic',
457+
contentHintText: 'Message #${channel.name}');
458+
});
459+
460+
testWidgets('with empty topic, content input has focus, then loses it', (tester) async {
461+
await prepare(tester, narrow: narrow, mandatoryTopics: false);
462+
await enterContent(tester, '');
463+
await tester.pump();
464+
checkComposeBoxHintTexts(tester,
465+
topicHintText: 'Topic',
466+
contentHintText: 'Message #${channel.name} > '
467+
'${eg.defaultRealmEmptyTopicDisplayName}');
468+
469+
FocusManager.instance.primaryFocus!.unfocus();
470+
await tester.pump();
471+
checkComposeBoxHintTexts(tester,
472+
topicHintText: 'Topic',
473+
contentHintText: 'Message #${channel.name} > '
474+
'${eg.defaultRealmEmptyTopicDisplayName}');
475+
});
476+
413477
testWidgets('with non-empty topic', (tester) async {
414478
await prepare(tester, narrow: narrow, mandatoryTopics: false);
415479
await enterTopic(tester, narrow: narrow, topic: 'new topic');

0 commit comments

Comments
 (0)