Skip to content

Commit 48b8bfb

Browse files
committed
compose: Change topic hint text conditionally
This is implemented to match web's behavior. See CZO discussion for design details: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/general.20chat.20design.20.23F1297/near/2106736 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 8cf9c37 commit 48b8bfb

12 files changed

+243
-16
lines changed

assets/l10n/app_en.arb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,13 @@
379379
"@composeBoxTopicHintText": {
380380
"description": "Hint text for topic input widget in compose box."
381381
},
382+
"composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)",
383+
"@composeBoxEnterTopicOrSkipHintText": {
384+
"description": "Hint text for topic input widget in compose box when topics are optional.",
385+
"placeholders": {
386+
"defaultTopicName": {"type": "String", "example": "general chat"}
387+
}
388+
},
382389
"composeBoxUploadingFilename": "Uploading {filename}…",
383390
"@composeBoxUploadingFilename": {
384391
"description": "Placeholder in compose box showing the specified file is currently uploading.",

lib/generated/l10n/zulip_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,12 @@ abstract class ZulipLocalizations {
609609
/// **'Topic'**
610610
String get composeBoxTopicHintText;
611611

612+
/// Hint text for topic input widget in compose box when topics are optional.
613+
///
614+
/// In en, this message translates to:
615+
/// **'Enter a topic (skip for “{defaultTopicName}”)'**
616+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName);
617+
612618
/// Placeholder in compose box showing the specified file is currently uploading.
613619
///
614620
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_en.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_ja.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_nb.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_pl.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Wątek';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Przekazywanie $filename…';

lib/generated/l10n/zulip_localizations_ru.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Тема';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Загрузка $filename…';

lib/generated/l10n/zulip_localizations_sk.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/widgets/compose_box.dart

Lines changed: 114 additions & 11 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
@@ -654,41 +669,121 @@ class _StreamContentInputState extends State<_StreamContentInput> {
654669
}
655670
}
656671

657-
class _TopicInput extends StatelessWidget {
672+
class _TopicInput extends StatefulWidget {
658673
const _TopicInput({required this.streamId, required this.controller});
659674

660675
final int streamId;
661676
final StreamComposeBoxController controller;
662677

678+
@override
679+
State<_TopicInput> createState() => _TopicInputState();
680+
}
681+
682+
class _TopicInputState extends State<_TopicInput> {
683+
@override
684+
void initState() {
685+
super.initState();
686+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
687+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
688+
}
689+
690+
@override
691+
void didUpdateWidget(covariant _TopicInput oldWidget) {
692+
super.didUpdateWidget(oldWidget);
693+
if (widget.controller.topicFocusNode != oldWidget.controller.topicFocusNode) {
694+
oldWidget.controller.topicFocusNode.removeListener(_topicFocusChanged);
695+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
696+
}
697+
if (widget.controller.hasChosenTopic != oldWidget.controller.hasChosenTopic) {
698+
oldWidget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
699+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
700+
}
701+
}
702+
703+
@override
704+
void dispose() {
705+
widget.controller.topicFocusNode.removeListener(_topicFocusChanged);
706+
widget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
707+
super.dispose();
708+
}
709+
710+
void _topicFocusChanged() {
711+
setState(() {
712+
// The relevant state lives on widget.controller.topicFocusNode itself.
713+
});
714+
if (widget.controller.topicFocusNode.hasFocus) {
715+
widget.controller.hasChosenTopic.value = false;
716+
}
717+
}
718+
719+
void _hasChosenTopicChanged() {
720+
setState(() {
721+
// The relevant state lives on widget.controller.hasChosenTopic itself.
722+
});
723+
}
724+
725+
/// Return the default topic display name to use, or `null` if topics are
726+
/// required.
727+
String? _defaultTopicDisplayName() {
728+
final store = PerAccountStoreWidget.of(context);
729+
if (store.realmMandatoryTopics) {
730+
return null;
731+
}
732+
733+
// TODO(server-10) simplify
734+
return store.zulipFeatureLevel >= 334
735+
? store.realmEmptyTopicDisplayName : kNoTopicTopic;
736+
}
737+
663738
@override
664739
Widget build(BuildContext context) {
665740
final zulipLocalizations = ZulipLocalizations.of(context);
666741
final designVariables = DesignVariables.of(context);
742+
final store = PerAccountStoreWidget.of(context);
667743
TextStyle topicTextStyle = TextStyle(
668744
fontSize: 20,
669745
height: 22 / 20,
670746
color: designVariables.textInput.withFadedAlpha(0.9),
671747
).merge(weightVariableTextStyle(context, wght: 600));
672748

749+
String hintText = zulipLocalizations.composeBoxTopicHintText;
750+
TextStyle hintStyle = topicTextStyle.copyWith(
751+
color: designVariables.textInput.withFadedAlpha(0.5));
752+
final defaultTopicDisplayName = _defaultTopicDisplayName();
753+
if (defaultTopicDisplayName != null) {
754+
if (widget.controller.topicFocusNode.hasFocus) {
755+
// The user is actively interacting with the input.
756+
// Show a long and engaging hint text.
757+
hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
758+
defaultTopicDisplayName);
759+
} else if (widget.controller.hasChosenTopic.value) {
760+
// The topic has been chosen. Show the default topic display name in
761+
// the input as if the user has entered that when they left it empty.
762+
hintText = defaultTopicDisplayName;
763+
hintStyle = topicTextStyle.copyWith(
764+
// TODO(server-10) simplify
765+
fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null);
766+
}
767+
}
768+
673769
return TopicAutocomplete(
674-
streamId: streamId,
675-
controller: controller.topic,
676-
focusNode: controller.topicFocusNode,
677-
contentFocusNode: controller.contentFocusNode,
770+
streamId: widget.streamId,
771+
controller: widget.controller.topic,
772+
focusNode: widget.controller.topicFocusNode,
773+
contentFocusNode: widget.controller.contentFocusNode,
678774
fieldViewBuilder: (context) => Container(
679775
padding: const EdgeInsets.only(top: 10, bottom: 9),
680776
decoration: BoxDecoration(border: Border(bottom: BorderSide(
681777
width: 1,
682778
color: designVariables.foreground.withFadedAlpha(0.2)))),
683779
child: TextField(
684-
controller: controller.topic,
685-
focusNode: controller.topicFocusNode,
780+
controller: widget.controller.topic,
781+
focusNode: widget.controller.topicFocusNode,
686782
textInputAction: TextInputAction.next,
687783
style: topicTextStyle,
688784
decoration: InputDecoration(
689-
hintText: zulipLocalizations.composeBoxTopicHintText,
690-
hintStyle: topicTextStyle.copyWith(
691-
color: designVariables.textInput.withFadedAlpha(0.5))))));
785+
hintText: hintText,
786+
hintStyle: hintStyle))));
692787
}
693788
}
694789

@@ -1366,10 +1461,18 @@ class StreamComposeBoxController extends ComposeBoxController {
13661461
final ComposeTopicController topic;
13671462
final topicFocusNode = FocusNode();
13681463

1464+
/// Whether the user has made up their mind choosing a topic.
1465+
///
1466+
/// Empirically, this should be set to `false` whenever the user focuses on
1467+
/// the topic input, and set to `true` whenever the user focuses on the
1468+
/// content input.
1469+
ValueNotifier<bool> hasChosenTopic = ValueNotifier(false);
1470+
13691471
@override
13701472
void dispose() {
13711473
topic.dispose();
13721474
topicFocusNode.dispose();
1475+
hasChosenTopic.dispose();
13731476
super.dispose();
13741477
}
13751478
}

test/flutter_checks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ extension TextStyleChecks on Subject<TextStyle> {
9999
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
100100
Subject<Color?> get color => has((t) => t.color, 'color');
101101
Subject<double?> get fontSize => has((t) => t.fontSize, 'fontSize');
102+
Subject<FontStyle?> get fontStyle => has((t) => t.fontStyle, 'fontStyle');
102103
Subject<FontWeight?> get fontWeight => has((t) => t.fontWeight, 'fontWeight');
103104
Subject<double?> get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing');
104105
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');
@@ -170,6 +171,7 @@ extension MaterialChecks on Subject<Material> {
170171

171172
extension InputDecorationChecks on Subject<InputDecoration> {
172173
Subject<String?> get hintText => has((x) => x.hintText, 'hintText');
174+
Subject<TextStyle?> get hintStyle => has((x) => x.hintStyle, 'hintStyle');
173175
}
174176

175177
extension RadioListTileChecks<T> on Subject<RadioListTile<T>> {

0 commit comments

Comments
 (0)