Skip to content

Commit b27bf22

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 224dee5 commit b27bf22

12 files changed

+234
-19
lines changed

assets/l10n/app_en.arb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,13 @@
367367
"@composeBoxTopicHintText": {
368368
"description": "Hint text for topic input widget in compose box."
369369
},
370+
"composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)",
371+
"@composeBoxEnterTopicOrSkipHintText": {
372+
"description": "Hint text for topic input widget in compose box when topics are optional.",
373+
"placeholders": {
374+
"defaultTopicName": {"type": "String", "example": "general chat"}
375+
}
376+
},
370377
"composeBoxUploadingFilename": "Uploading {filename}…",
371378
"@composeBoxUploadingFilename": {
372379
"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
@@ -591,6 +591,12 @@ abstract class ZulipLocalizations {
591591
/// **'Topic'**
592592
String get composeBoxTopicHintText;
593593

594+
/// Hint text for topic input widget in compose box when topics are optional.
595+
///
596+
/// In en, this message translates to:
597+
/// **'Enter a topic (skip for “{defaultTopicName}”)'**
598+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName);
599+
594600
/// Placeholder in compose box showing the specified file is currently uploading.
595601
///
596602
/// 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
@@ -289,6 +289,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
289289
@override
290290
String get composeBoxTopicHintText => 'Topic';
291291

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_en.dart

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

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_ja.dart

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

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_nb.dart

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

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_pl.dart

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

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Przekazywanie $filename…';

lib/generated/l10n/zulip_localizations_ru.dart

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

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Загрузка $filename…';

lib/generated/l10n/zulip_localizations_sk.dart

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

292+
@override
293+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
294+
return 'Enter a topic (skip for “$defaultTopicName”)';
295+
}
296+
292297
@override
293298
String composeBoxUploadingFilename(String filename) {
294299
return 'Uploading $filename…';

lib/widgets/compose_box.dart

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
202202
// https://github.com/zulip/zulip-flutter/pull/1148#discussion_r1941990585
203203
String getDestinationString({
204204
required String streamName,
205-
required bool contentHasFocus,
205+
required bool hasChosenTopic,
206206
}) {
207207
final textTrimmed = text.trim();
208208
if (textTrimmed.isNotEmpty) {
@@ -217,7 +217,7 @@ class ComposeTopicController extends ComposeController<TopicValidationError> {
217217
// Do not fall back to a vacuous topic unless the user explicitly chooses
218218
// to do so (by skipping topic input and moving focus to content input),
219219
// because we expect a call to action for the user to pick one first.
220-
|| !contentHasFocus
220+
|| !hasChosenTopic
221221
) {
222222
return '#$streamName';
223223
}
@@ -618,16 +618,26 @@ class _StreamContentInputState extends State<_StreamContentInput> {
618618
});
619619
}
620620

621+
void _hasChosenTopicChanged() {
622+
setState(() {
623+
// The relevant state lives on widget.controller.hasChosenTopic itself.
624+
});
625+
}
626+
621627
void _contentFocusChanged() {
622628
setState(() {
623629
// The relevant state lives on widget.controller.contentFocusNode itself.
624630
});
631+
if (widget.controller.contentFocusNode.hasFocus){
632+
widget.controller.hasChosenTopic.value = true;
633+
}
625634
}
626635

627636
@override
628637
void initState() {
629638
super.initState();
630639
widget.controller.topic.addListener(_topicChanged);
640+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
631641
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
632642
}
633643

@@ -638,6 +648,10 @@ class _StreamContentInputState extends State<_StreamContentInput> {
638648
oldWidget.controller.topic.removeListener(_topicChanged);
639649
widget.controller.topic.addListener(_topicChanged);
640650
}
651+
if (widget.controller.hasChosenTopic != oldWidget.controller.hasChosenTopic) {
652+
oldWidget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
653+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
654+
}
641655
if (widget.controller.contentFocusNode != oldWidget.controller.contentFocusNode) {
642656
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
643657
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
@@ -647,6 +661,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
647661
@override
648662
void dispose() {
649663
widget.controller.topic.removeListener(_topicChanged);
664+
widget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
650665
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
651666
super.dispose();
652667
}
@@ -665,45 +680,113 @@ class _StreamContentInputState extends State<_StreamContentInput> {
665680
hintText: zulipLocalizations.composeBoxChannelContentHint(
666681
widget.controller.topic.getDestinationString(
667682
streamName: streamName,
668-
contentHasFocus: widget.controller.contentFocusNode.hasFocus)));
683+
hasChosenTopic: widget.controller.hasChosenTopic.value)));
669684
}
670685
}
671686

672-
class _TopicInput extends StatelessWidget {
687+
class _TopicInput extends StatefulWidget {
673688
const _TopicInput({required this.streamId, required this.controller});
674689

675690
final int streamId;
676691
final StreamComposeBoxController controller;
677692

693+
@override
694+
State<_TopicInput> createState() => _TopicInputState();
695+
}
696+
697+
class _TopicInputState extends State<_TopicInput> {
698+
@override
699+
void initState() {
700+
super.initState();
701+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
702+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
703+
}
704+
705+
@override
706+
void didUpdateWidget(covariant _TopicInput oldWidget) {
707+
super.didUpdateWidget(oldWidget);
708+
if (widget.controller.topicFocusNode != oldWidget.controller.topicFocusNode) {
709+
oldWidget.controller.topicFocusNode.removeListener(_topicFocusChanged);
710+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
711+
}
712+
if (widget.controller.hasChosenTopic != oldWidget.controller.hasChosenTopic) {
713+
oldWidget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
714+
widget.controller.hasChosenTopic.addListener(_hasChosenTopicChanged);
715+
}
716+
}
717+
718+
@override
719+
void dispose() {
720+
widget.controller.topicFocusNode.removeListener(_topicFocusChanged);
721+
widget.controller.hasChosenTopic.removeListener(_hasChosenTopicChanged);
722+
super.dispose();
723+
}
724+
725+
void _topicFocusChanged() {
726+
setState(() {
727+
// The relevant state lives on widget.controller.topicFocusNode itself.
728+
});
729+
if (widget.controller.topicFocusNode.hasFocus) {
730+
widget.controller.hasChosenTopic.value = false;
731+
}
732+
}
733+
734+
void _hasChosenTopicChanged() {
735+
setState(() {
736+
// The relevant state lives on widget.controller.hasChosenTopic itself.
737+
});
738+
}
739+
678740
@override
679741
Widget build(BuildContext context) {
680742
final zulipLocalizations = ZulipLocalizations.of(context);
681743
final designVariables = DesignVariables.of(context);
744+
final store = PerAccountStoreWidget.of(context);
682745
TextStyle topicTextStyle = TextStyle(
683746
fontSize: 20,
684747
height: 22 / 20,
685748
color: designVariables.textInput.withFadedAlpha(0.9),
686749
).merge(weightVariableTextStyle(context, wght: 600));
750+
final hintStyle = topicTextStyle.copyWith(
751+
color: designVariables.textInput.withFadedAlpha(0.5));
752+
753+
final defaultTopicDisplayName = store.zulipFeatureLevel >= 334
754+
? store.realmEmptyTopicDisplayName : kNoTopicTopic;
755+
756+
final decoration = switch ((
757+
store.realmMandatoryTopics,
758+
widget.controller.hasChosenTopic.value,
759+
widget.controller.topicFocusNode.hasFocus,
760+
)) {
761+
(false, true, _) => InputDecoration(
762+
hintText: defaultTopicDisplayName,
763+
hintStyle: topicTextStyle.copyWith(
764+
fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null)),
765+
(false, false, true) => InputDecoration(
766+
hintText: zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
767+
defaultTopicDisplayName),
768+
hintStyle: hintStyle),
769+
(_, _, _) => InputDecoration(
770+
hintText: zulipLocalizations.composeBoxTopicHintText,
771+
hintStyle: hintStyle),
772+
};
687773

688774
return TopicAutocomplete(
689-
streamId: streamId,
690-
controller: controller.topic,
691-
focusNode: controller.topicFocusNode,
692-
contentFocusNode: controller.contentFocusNode,
775+
streamId: widget.streamId,
776+
controller: widget.controller.topic,
777+
focusNode: widget.controller.topicFocusNode,
778+
contentFocusNode: widget.controller.contentFocusNode,
693779
fieldViewBuilder: (context) => Container(
694780
padding: const EdgeInsets.only(top: 10, bottom: 9),
695781
decoration: BoxDecoration(border: Border(bottom: BorderSide(
696782
width: 1,
697783
color: designVariables.foreground.withFadedAlpha(0.2)))),
698784
child: TextField(
699-
controller: controller.topic,
700-
focusNode: controller.topicFocusNode,
785+
controller: widget.controller.topic,
786+
focusNode: widget.controller.topicFocusNode,
701787
textInputAction: TextInputAction.next,
702788
style: topicTextStyle,
703-
decoration: InputDecoration(
704-
hintText: zulipLocalizations.composeBoxTopicHintText,
705-
hintStyle: topicTextStyle.copyWith(
706-
color: designVariables.textInput.withFadedAlpha(0.5))))));
789+
decoration: decoration)));
707790
}
708791
}
709792

@@ -1381,10 +1464,18 @@ class StreamComposeBoxController extends ComposeBoxController {
13811464
final ComposeTopicController topic;
13821465
final topicFocusNode = FocusNode();
13831466

1467+
/// Whether the user has made up their mind choosing a topic.
1468+
///
1469+
/// Empirically, this should be set to `false` whenever the user focuses on
1470+
/// the topic input, and set to `true` whenever the user focuses on the
1471+
/// content input.
1472+
ValueNotifier<bool> hasChosenTopic = ValueNotifier(false);
1473+
13841474
@override
13851475
void dispose() {
13861476
topic.dispose();
13871477
topicFocusNode.dispose();
1478+
hasChosenTopic.dispose();
13881479
super.dispose();
13891480
}
13901481
}

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 BoxDecorationChecks on Subject<BoxDecoration> {

0 commit comments

Comments
 (0)