Skip to content

Commit 5c5e25f

Browse files
committed
compose: Change topic input hint text
This is similar to web's behavior. When topics are not mandatory: - an alternative hint text "Enter a topic (skip for “general chat”)" is shown when the topic input has focus; - an opaque placeholder text (e.g.: "general chat") is shown if the user skipped to content input; Because the topic input is always shown in a message list page channel narrow (assuming permission to send messages), this also adds an initial state: - a short hint text, "Topic", is shown if the user hasn't interacted with topic or content inputs at all, or when the user unfocused topic input without moving focus to content input. This only changes the topic input's hint text. See CZO discussion for design details: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/general.20chat.20design.20.23F1297/near/2106736
1 parent 7e7cca4 commit 5c5e25f

12 files changed

+253
-10
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: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -680,15 +680,104 @@ class _TopicInput extends StatefulWidget {
680680
}
681681

682682
class _TopicInputState extends State<_TopicInput> {
683+
void _topicFocusChanged() {
684+
setState(() {
685+
if (widget.controller.topicFocusNode.hasFocus) {
686+
widget.controller.topicInteractionStatus.value =
687+
ComposeTopicInteractionStatus.isEditing;
688+
} else if (!widget.controller.contentFocusNode.hasFocus) {
689+
widget.controller.topicInteractionStatus.value =
690+
ComposeTopicInteractionStatus.notEditingNotChosen;
691+
}
692+
});
693+
}
694+
695+
void _contentFocusChanged() {
696+
setState(() {
697+
if (widget.controller.contentFocusNode.hasFocus) {
698+
widget.controller.topicInteractionStatus.value =
699+
ComposeTopicInteractionStatus.hasChosen;
700+
}
701+
});
702+
}
703+
704+
void _topicInteractionStatusChanged() {
705+
setState(() {
706+
// The actual state lives in widget.controller.topicInteractionStatus
707+
});
708+
}
709+
710+
@override
711+
void initState() {
712+
super.initState();
713+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
714+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
715+
widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged);
716+
}
717+
718+
@override
719+
void didUpdateWidget(covariant _TopicInput oldWidget) {
720+
super.didUpdateWidget(oldWidget);
721+
if (oldWidget.controller != widget.controller) {
722+
oldWidget.controller.topicFocusNode.removeListener(_topicFocusChanged);
723+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
724+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
725+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
726+
oldWidget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged);
727+
widget.controller.topicInteractionStatus.addListener(_topicInteractionStatusChanged);
728+
}
729+
}
730+
731+
@override
732+
void dispose() {
733+
widget.controller.topicFocusNode.removeListener(_topicFocusChanged);
734+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
735+
widget.controller.topicInteractionStatus.removeListener(_topicInteractionStatusChanged);
736+
super.dispose();
737+
}
738+
683739
@override
684740
Widget build(BuildContext context) {
685741
final zulipLocalizations = ZulipLocalizations.of(context);
686742
final designVariables = DesignVariables.of(context);
687-
TextStyle topicTextStyle = TextStyle(
743+
final store = PerAccountStoreWidget.of(context);
744+
745+
final topicTextStyle = TextStyle(
688746
fontSize: 20,
689747
height: 22 / 20,
690748
color: designVariables.textInput.withFadedAlpha(0.9),
691749
).merge(weightVariableTextStyle(context, wght: 600));
750+
final hintStyle = topicTextStyle.copyWith(
751+
color: designVariables.textInput.withFadedAlpha(0.5));
752+
final defaultTopicDisplayName = store.zulipFeatureLevel >= 334
753+
? store.realmEmptyTopicDisplayName : kNoTopicTopic;
754+
755+
final decoration = switch ((
756+
store.realmMandatoryTopics,
757+
widget.controller.topicInteractionStatus.value,
758+
)) {
759+
(false, ComposeTopicInteractionStatus.hasChosen) => InputDecoration(
760+
// The topic has likely been chosen. Since topics are not mandaotry,
761+
// show the default topic display name as if the user has entered that
762+
// when they left the input empty.
763+
hintText: defaultTopicDisplayName,
764+
hintStyle: topicTextStyle.copyWith(
765+
fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null)),
766+
767+
(false, ComposeTopicInteractionStatus.isEditing) => InputDecoration(
768+
// The user is actively interacting with the input. Since topics are
769+
// not mandatory, show a long hint text mentioning that they can be
770+
// left empty.
771+
hintText: zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
772+
defaultTopicDisplayName),
773+
hintStyle: hintStyle),
774+
775+
(false, ComposeTopicInteractionStatus.notEditingNotChosen) ||
776+
(true, _) => InputDecoration(
777+
// Otherwise, show a short hint text for less distraction.
778+
hintText: zulipLocalizations.composeBoxTopicHintText,
779+
hintStyle: hintStyle),
780+
};
692781

693782
return TopicAutocomplete(
694783
streamId: widget.streamId,
@@ -705,10 +794,7 @@ class _TopicInputState extends State<_TopicInput> {
705794
focusNode: widget.controller.topicFocusNode,
706795
textInputAction: TextInputAction.next,
707796
style: topicTextStyle,
708-
decoration: InputDecoration(
709-
hintText: zulipLocalizations.composeBoxTopicHintText,
710-
hintStyle: topicTextStyle.copyWith(
711-
color: designVariables.textInput.withFadedAlpha(0.5))))));
797+
decoration: decoration)));
712798
}
713799
}
714800

@@ -1381,17 +1467,67 @@ sealed class ComposeBoxController {
13811467
}
13821468
}
13831469

1470+
/// Represent how a user has interacted with topic and content inputs.
1471+
///
1472+
/// State-transition diagram:
1473+
///
1474+
/// ```
1475+
/// (default)
1476+
/// Topic input │ Content input
1477+
/// lost focus. ▼ gained focus.
1478+
/// ┌────────────► notEditingNotChosen ────────────┐
1479+
/// │ │ │
1480+
/// │ Topic input │ │
1481+
/// │ gained focus. │ │
1482+
/// │ ◄─────────────────────────┘ ▼
1483+
/// isEditing ◄───────────────────────────── hasChosen
1484+
/// │ Focus moved from ▲
1485+
/// │ content to topic. │
1486+
/// │ │
1487+
/// └──────────────────────────────────────────────┘
1488+
/// Focus moved from
1489+
/// topic to content.
1490+
/// ```
1491+
///
1492+
/// This state machine offers the following invariants:
1493+
/// - When topic input has focus, the status must be [isEditing].
1494+
/// - When content input has focus, the status must be [hasChosen].
1495+
/// - When neither inputs has focus, and content input was the last
1496+
/// input among the two to be focused, the status must be [hasChosen].
1497+
/// - Otherwise, the status must be [notEditingNotChosen].
1498+
enum ComposeTopicInteractionStatus {
1499+
/// The topic has likely not been chosen if left empty,
1500+
/// and is not being actively edited.
1501+
///
1502+
/// When in this status neither the topic input nor the content input has focus.
1503+
notEditingNotChosen,
1504+
1505+
/// The topic is being actively edited.
1506+
///
1507+
/// When in this status, the topic input must have focus.
1508+
isEditing,
1509+
1510+
/// The topic has likely been chosen, even if it is left empty.
1511+
///
1512+
/// When in this status, the topic input must have no focus;
1513+
/// the content input might have focus.
1514+
hasChosen,
1515+
}
1516+
13841517
class StreamComposeBoxController extends ComposeBoxController {
13851518
StreamComposeBoxController({required PerAccountStore store})
13861519
: topic = ComposeTopicController(store: store);
13871520

13881521
final ComposeTopicController topic;
13891522
final topicFocusNode = FocusNode();
1523+
final ValueNotifier<ComposeTopicInteractionStatus> topicInteractionStatus =
1524+
ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen);
13901525

13911526
@override
13921527
void dispose() {
13931528
topic.dispose();
13941529
topicFocusNode.dispose();
1530+
topicInteractionStatus.dispose();
13951531
super.dispose();
13961532
}
13971533
}

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)